diff --git a/README.md b/README.md index 490b4c0e..75e88d28 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,10 @@ The key incantations are: `-n` Disables computer discovery, takes a comma-separated list of hosts or input file to do share and file discovery on. Note if supplying a file, the input needs to be a path so C:\targets.txt or .\targets.txt as an example. +`-k` Excludes specific hosts from scanning. Accepts a comma-separated list of hostnames, IPs, and/or CIDR ranges (e.g. `-k 10.1.2.0/24,192.168.5.10`), or a path to a file containing one entry per line. Hostnames are resolved to IPs at startup. CIDR ranges are matched against each computer's resolved IP at scan time. + +`-q` Queries AD for computers, then only scans those resolving within the specified CIDRs. Comma-separated. e.g. `-q 10.1.2.0/24,10.1.4.0/24`. Can be combined with `-k` to further exclude specific hosts. + `-y` TSV-formats the output. `-b` Skips the LAIM rules that will find less-interesting stuff, tune it with a number between 0 and 3. diff --git a/SnaffCore/Config/Options.cs b/SnaffCore/Config/Options.cs index 78c66282..6e5c1e1b 100644 --- a/SnaffCore/Config/Options.cs +++ b/SnaffCore/Config/Options.cs @@ -20,6 +20,7 @@ public partial class Options public string ComputerTargetsLdapFilter { get; set; } = "(objectClass=computer)"; public string ComputerExclusionFile { get; set; } public List ComputerExclusions { get; set; } = new List(); + public List CidrInclusions { get; set; } = new List(); public bool ScanSysvol { get; set; } = true; public bool ScanNetlogon { get; set; } = true; public bool ScanFoundShares { get; set; } = true; diff --git a/SnaffCore/NetworkUtils.cs b/SnaffCore/NetworkUtils.cs new file mode 100644 index 00000000..c708e19d --- /dev/null +++ b/SnaffCore/NetworkUtils.cs @@ -0,0 +1,55 @@ +using System.Net; +using System.Text.RegularExpressions; + +namespace SnaffCore +{ + public static class NetworkUtils + { + // Matches network/prefix notation e.g. 10.1.2.0/24 or 2001:db8::/32 + public static readonly Regex CidrRegex = new Regex(@"^\S+/\d+$", RegexOptions.Compiled); + /// + /// Checks whether an IP address falls within a CIDR range. + /// Supports both IPv4 and IPv6. + /// + public static bool IsInCidr(IPAddress address, string cidr) + { + // Split "network/prefixLength" e.g. "10.1.2.0/24" -> ["10.1.2.0", "24"] + string[] parts = cidr.Split('/'); + if (parts.Length != 2 || !int.TryParse(parts[1], out int prefixLength)) + return false; + if (!IPAddress.TryParse(parts[0], out IPAddress network)) + return false; + + // GetAddressBytes() returns big-endian bytes: 4 for IPv4, 16 for IPv6 + // Mismatched lengths means comparing IPv4 against IPv6 or vice versa + byte[] addrBytes = address.GetAddressBytes(); + byte[] netBytes = network.GetAddressBytes(); + + if (addrBytes.Length != netBytes.Length) + return false; + + // Check all bytes that are fully covered by the prefix + // e.g. /24 covers 3 full bytes, /20 covers 2 full bytes + int fullBytes = prefixLength / 8; + int remainingBits = prefixLength % 8; + + for (int i = 0; i < fullBytes; i++) + { + if (addrBytes[i] != netBytes[i]) + return false; + } + + // Check the partial byte (if the prefix doesn't fall on a byte boundary) + // e.g. /20 has 4 remaining bits: mask = 11110000 = 0xF0 + // We only compare the masked bits, ignoring the host portion + if (remainingBits > 0) + { + byte mask = (byte)(0xFF << (8 - remainingBits)); + if ((addrBytes[fullBytes] & mask) != (netBytes[fullBytes] & mask)) + return false; + } + + return true; + } + } +} diff --git a/SnaffCore/SnaffCon.cs b/SnaffCore/SnaffCon.cs index 5334939d..bcad0a87 100644 --- a/SnaffCore/SnaffCon.cs +++ b/SnaffCore/SnaffCon.cs @@ -33,6 +33,7 @@ public class SnaffCon private static TreeWalker TreeWalker; private static FileScanner FileScanner; + private AdData _adData = AdData.AdDataInstance; private DateTime StartTime { get; set; } @@ -288,11 +289,19 @@ public void PrepDomainUserRules() private void ShareDiscovery(string[] computerTargets) { Mq.Info("Starting to look for readable shares..."); + int excludedCount = 0; + int cidrFilteredCount = 0; + bool hasCidrInclusions = MyOptions.CidrInclusions.Count > 0; foreach (string computer in computerTargets) { if (CheckExclusions(computer)) { - // skip any that are in the exclusion list + excludedCount++; + continue; + } + if (hasCidrInclusions && !CheckInclusions(computer)) + { + cidrFilteredCount++; continue; } // Perform reverse lookup if the computer is an IP address @@ -334,6 +343,16 @@ private void ShareDiscovery(string[] computerTargets) } }); } + int postExclusion = computerTargets.Length - excludedCount; + int included = postExclusion - cidrFilteredCount; + if (excludedCount > 0) + { + Mq.Info("Excluded " + excludedCount + " of " + computerTargets.Length + " computers (exclusion list or DNS resolution failure)."); + } + if (cidrFilteredCount > 0) + { + Mq.Info("CIDR target filter: kept " + included + " of " + postExclusion + " remaining computers."); + } Mq.Info("Created all sharefinder tasks."); } @@ -348,15 +367,16 @@ private bool CheckExclusions(string computer) // check if it's an IP already if (isIP(computer)) { - // check if it's in the exclusions list - if (MyOptions.ComputerExclusions.Contains(computer)) + IPAddress addr = IPAddress.Parse(computer); + // check if it's in the exclusions list or falls within an excluded CIDR + if (MyOptions.ComputerExclusions.Contains(computer) || IsInAnyCidr(addr)) { // if so, skip it Mq.Degub("Excluded " + computer); return true; } } - else { + else { try { // resolve it @@ -364,8 +384,8 @@ private bool CheckExclusions(string computer) // handle multiple IPs in response foreach (IPAddress ipAddress in result.AddressList) { - // if any of them is in the exclusion list - if (MyOptions.ComputerExclusions.Contains(ipAddress.ToString())) + // if any of them is in the exclusion list or falls within an excluded CIDR + if (MyOptions.ComputerExclusions.Contains(ipAddress.ToString()) || IsInAnyCidr(ipAddress)) { Mq.Degub("Excluded " + computer + " at " + ipAddress); @@ -388,6 +408,52 @@ private bool CheckExclusions(string computer) return false; } + private bool IsInAnyCidr(IPAddress address) + { + foreach (string entry in MyOptions.ComputerExclusions) + { + if (NetworkUtils.CidrRegex.IsMatch(entry) && NetworkUtils.IsInCidr(address, entry)) + return true; + } + return false; + } + + private bool CheckInclusions(string computer) + { + // Returns true if the computer resolves to an IP within any of the CIDR inclusions + try + { + IPAddress[] addresses; + if (isIP(computer)) + { + addresses = new[] { IPAddress.Parse(computer) }; + } + else + { + addresses = Dns.GetHostEntry(computer).AddressList; + } + + foreach (IPAddress addr in addresses) + { + foreach (string cidr in MyOptions.CidrInclusions) + { + if (NetworkUtils.IsInCidr(addr, cidr)) + { + Mq.Degub("CIDR included " + computer + " at " + addr); + return true; + } + } + } + } + catch (Exception ex) + { + Mq.Degub(ex.Message); + } + + Mq.Degub("CIDR filtered out " + computer); + return false; + } + private void FileDiscovery(string[] pathTargets) { foreach (string pathTarget in pathTargets) diff --git a/SnaffCore/SnaffCore.csproj b/SnaffCore/SnaffCore.csproj index fa0fc804..0e2772fa 100644 --- a/SnaffCore/SnaffCore.csproj +++ b/SnaffCore/SnaffCore.csproj @@ -88,6 +88,7 @@ + diff --git a/Snaffler/Config.cs b/Snaffler/Config.cs index e9aedb21..04a2f091 100644 --- a/Snaffler/Config.cs +++ b/Snaffler/Config.cs @@ -1,472 +1,516 @@ -using CommandLineParser.Arguments; -using Nett; -using NLog; -using SnaffCore.Concurrency; -using SnaffCore.Config; -using System; -using System.Resources; -using System.IO; -using System.Linq; -using System.Text; -using System.Reflection; -using System.Security; -using System.Collections.Generic; -using System.Net; - -namespace Snaffler -{ - public static class Config - { - public static Options Parse(string[] args) - { - BlockingMq Mq = BlockingMq.GetMq(); - Options options; - - // parse the args - try - { - options = ParseImpl(args); - if (options == null) - { - return null; - } - } - catch - { - Mq.Error("Something went wrong parsing args."); - throw; - } - - Mq.Info("Parsed args successfully."); - return options; - } - - - public static string ReadResource(string name) - { - // Determine path - var assembly = Assembly.GetExecutingAssembly(); - string resourcePath = name; - // Format: "{Namespace}.{Folder}.{filename}.{Extension}" - - using (Stream stream = assembly.GetManifestResourceStream(resourcePath)) - using (StreamReader reader = new StreamReader(stream)) - { - return reader.ReadToEnd(); - } - } - - public static bool isIP(string host) - { - IPAddress ip; - return IPAddress.TryParse(host, out ip); - } - - private static Options ParseImpl(string[] args) - { - BlockingMq Mq = BlockingMq.GetMq(); - Mq.Info("Parsing args..."); - Options parsedConfig = new Options(); - - // define args - ValueArgument configFileArg = new ValueArgument('z', "config", "Path to a .toml config file. Run with \'generate\' to puke a sample config file into the working directory."); - ValueArgument outFileArg = new ValueArgument('o', "outfile", - "Path for output file. You probably want this if you're not using -s."); - ValueArgument verboseArg = new ValueArgument('v', "verbosity", - "Controls verbosity level, options are Trace (most verbose), Debug (less verbose), Info (less verbose still, default), and Data (results only). e.g '-v debug' "); - SwitchArgument helpArg = new SwitchArgument('h', "help", "Displays this help.", false); - SwitchArgument stdOutArg = new SwitchArgument('s', "stdout", - "Enables outputting results to stdout as soon as they're found. You probably want this if you're not using -o.", - false); - ValueArgument interestLevel = new ValueArgument('b', "interest", "Interest level to report (0-3)"); - ValueArgument snaffleArg = new ValueArgument('m', "snaffle", - "Enables and assigns an output dir for Snaffler to automatically snaffle a copy of any found files."); - ValueArgument snaffleSizeArg = new ValueArgument('l', "snafflesize", "Maximum size of file to snaffle, in bytes. Defaults to 10MB."); - //var fileHuntArg = new SwitchArgument('f', "filehuntoff", - // "Disables file discovery, will only perform computer and share discovery.", false); - ValueArgument dirTargetArg = new ValueArgument('i', "dirtarget", - "Disables computer and share discovery, requires a path to a directory in which to perform file discovery."); - ValueArgument domainArg = new ValueArgument('d', "domain", - "Domain to search for computers to search for shares on to search for files in. Easy."); - ValueArgument domainControllerArg = new ValueArgument('c', "domaincontroller", - "Domain controller to query for a list of domain computers."); - ValueArgument maxGrepSizeArg = new ValueArgument('r', "maxgrepsize", - "The maximum size file (in bytes) to search inside for interesting strings. Defaults to 500k."); - ValueArgument grepContextArg = new ValueArgument('j', "grepcontext", - "How many bytes of context either side of found strings in files to show, e.g. -j 200"); - SwitchArgument domainUserArg = new SwitchArgument('u', "domainusers", "Makes Snaffler grab a list of interesting-looking accounts from the domain and uses them in searches.", false); - ValueArgument maxThreadsArg = new ValueArgument('x', "maxthreads", "How many threads to be snaffling with. Any less than 4 and you're gonna have a bad time."); - SwitchArgument tsvArg = new SwitchArgument('y', "tsv", "Makes Snaffler output as tsv.", false); - SwitchArgument dfsArg = new SwitchArgument('f', "dfs", "Limits Snaffler to finding file shares via DFS, for \"OPSEC\" reasons.", false); - SwitchArgument findSharesOnlyArg = new SwitchArgument('a', "sharesonly", - "Stops after finding shares, doesn't walk their filesystems.", false); - ValueArgument compExclusionArg = new ValueArgument('k', "exclusions", "Path to a file containing a list of computers to exclude from scanning."); - ValueArgument compTargetArg = new ValueArgument('n', "comptarget", "List of computers in a file(e.g C:\targets.txt), a single Computer (or comma separated list) to target."); - ValueArgument ruleDirArg = new ValueArgument('p', "rulespath", "Path to a directory full of toml-formatted rules. Snaffler will load all of these in place of the default ruleset."); - ValueArgument logType = new ValueArgument('t', "logtype", "Type of log you would like to output. Currently supported options are plain and JSON. Defaults to plain."); - ValueArgument timeOutArg = new ValueArgument('e', "timeout", - "Interval between status updates (in minutes) also acts as a timeout for AD data to be gathered via LDAP. Turn this knob up if you aren't getting any computers from AD when you run Snaffler through a proxy or other slow link. Default = 5"); - // list of letters i haven't used yet: gnqw - - CommandLineParser.CommandLineParser parser = new CommandLineParser.CommandLineParser(); - parser.Arguments.Add(timeOutArg); - parser.Arguments.Add(configFileArg); - parser.Arguments.Add(outFileArg); - parser.Arguments.Add(helpArg); - parser.Arguments.Add(stdOutArg); - parser.Arguments.Add(snaffleArg); - parser.Arguments.Add(snaffleSizeArg); - parser.Arguments.Add(dirTargetArg); - parser.Arguments.Add(interestLevel); - parser.Arguments.Add(domainArg); - parser.Arguments.Add(verboseArg); - parser.Arguments.Add(domainControllerArg); - parser.Arguments.Add(maxGrepSizeArg); - parser.Arguments.Add(grepContextArg); - parser.Arguments.Add(domainUserArg); - parser.Arguments.Add(tsvArg); - parser.Arguments.Add(dfsArg); - parser.Arguments.Add(findSharesOnlyArg); - parser.Arguments.Add(maxThreadsArg); - parser.Arguments.Add(compTargetArg); - parser.Arguments.Add(ruleDirArg); - parser.Arguments.Add(logType); - parser.Arguments.Add(compExclusionArg); - - // extra check to handle builtin behaviour from cmd line arg parser - if ((args.Contains("--help") || args.Contains("/?") || args.Contains("help") || args.Contains("-h") || args.Length == 0)) - { - parser.ShowUsage(); - return null; - } - - TomlSettings settings = TomlSettings.Create(cfg => cfg -.ConfigureType(tc => - tc.WithConversionFor(conv => conv - .FromToml(s => (LogLevel)Enum.Parse(typeof(LogLevel), s.Value, ignoreCase: true)) - .ToToml(e => e.ToString())))); - - try - { - parser.ParseCommandLine(args); - - if (timeOutArg.Parsed && !String.IsNullOrWhiteSpace(timeOutArg.Value)) - { - int timeOutVal; - if (int.TryParse(timeOutArg.Value, out timeOutVal)) - { - Mq.Info("Set timeout/update interval to " + timeOutVal.ToString() + " minutes."); - parsedConfig.TimeOut = timeOutVal; - } - else - { - Mq.Error("Invalid timeout value passed, defaulting to 5 mins."); - } - } - - if (logType.Parsed && !String.IsNullOrWhiteSpace(logType.Value)) - { - //Set the default to plain - parsedConfig.LogType = LogType.Plain; - //if they set a different type then replace it with the new type. - if (logType.Value.ToLower() == "json") - { - parsedConfig.LogType = LogType.JSON; - } - else - { - Mq.Info("Invalid type argument passed (" + logType.Value + ") defaulting to plaintext"); - } - } - - if (ruleDirArg.Parsed && !String.IsNullOrWhiteSpace(ruleDirArg.Value)) - { - parsedConfig.RuleDir = ruleDirArg.Value; - } - - // get the args into our config - - // output args - if (outFileArg.Parsed && (!String.IsNullOrEmpty(outFileArg.Value))) - { - parsedConfig.LogToFile = true; - parsedConfig.LogFilePath = outFileArg.Value; - Mq.Degub("Logging to file at " + parsedConfig.LogFilePath); - } - - if (dfsArg.Parsed) - { - parsedConfig.DfsOnly = dfsArg.Value; - } - if (compExclusionArg.Parsed) - { - List compExclusions = new List(); - string[] fileLines = File.ReadAllLines(compExclusionArg.Value); - foreach (string line in fileLines) - { - if (isIP(line)) - { - compExclusions.Add(line); - } - else - { - try - { - IPHostEntry result = Dns.GetHostEntry(line); - foreach (IPAddress ipAddress in result.AddressList) - { - compExclusions.Add(ipAddress.ToString()); - } - } - catch (Exception ex) - { - Console.WriteLine(ex.Message); - continue; - } - } - } - if (compExclusions.Count > 0) - { - parsedConfig.ComputerExclusions = compExclusions; - parsedConfig.ComputerExclusionFile = compExclusionArg.Value; - } - else - { - throw new Exception("Failed to get a valid list of excluded computers from the excluded computers list."); - } - } - - if (compTargetArg.Parsed) - { - List compTargets = new List(); - if (compTargetArg.Value.Contains(Path.DirectorySeparatorChar)) - { - compTargets.AddRange(File.ReadLines(compTargetArg.Value).Select(line => line.Trim())); - } - else if (compTargetArg.Value.Contains(",")) - { - compTargets.AddRange(compTargetArg.Value.Split(',').Select(x => x.Trim())); - } - else - { - compTargets.Add(compTargetArg.Value.Trim()); - } - parsedConfig.ComputerTargets = compTargets.ToArray(); - } - - if (findSharesOnlyArg.Parsed) - { - parsedConfig.ScanFoundShares = false; - } - if (maxThreadsArg.Parsed) - { - parsedConfig.MaxThreads = maxThreadsArg.Value; - } - - parsedConfig.ShareThreads = parsedConfig.MaxThreads / 3; - parsedConfig.FileThreads = parsedConfig.MaxThreads / 3; - parsedConfig.TreeThreads = parsedConfig.MaxThreads / 3; - - if (tsvArg.Parsed) - { - parsedConfig.LogTSV = true; - if (parsedConfig.Separator == ' ') - { - parsedConfig.Separator = '\t'; - } - } - - // Set loglevel. - if (verboseArg.Parsed) - { - parsedConfig.LogLevelString = verboseArg.Value; - Mq.Degub("Requested verbosity level: " + parsedConfig.LogLevelString); - } - - // if enabled, display findings to the console - parsedConfig.LogToConsole = stdOutArg.Parsed; - Mq.Degub("Enabled logging to stdout."); - - // args that tell us about targeting - if ((domainArg.Parsed) && (!String.IsNullOrEmpty(domainArg.Value))) - { - parsedConfig.TargetDomain = domainArg.Value; - Mq.Degub("Target domain is " + domainArg.Value); - } - - if ((domainControllerArg.Parsed) && (!String.IsNullOrEmpty(domainControllerArg.Value))) - { - parsedConfig.TargetDc = domainControllerArg.Value; - Mq.Degub("Target DC is " + domainControllerArg.Value); - } - - if (domainUserArg.Parsed) - { - parsedConfig.DomainUserRules = true; - Mq.Degub("Enabled use of domain user accounts in rules."); - } - - if (dirTargetArg.Parsed) - { - parsedConfig.ShareFinderEnabled = false; - Mq.Degub("Disabled finding shares."); - - if (dirTargetArg.Value.Contains(",")) - { - string pathTargetsString = dirTargetArg.Value; - string[] pathTargets = pathTargetsString.Split(','); - foreach (string pathTarget in pathTargets) - { - if (pathTarget.Length > 4) - { - parsedConfig.PathTargets.Add(pathTarget.TrimEnd('\\')); - } - else - { - parsedConfig.PathTargets.Add(pathTarget); - } - } - } - else - { - string pathTarget = dirTargetArg.Value; - if (dirTargetArg.Value.Length > 4) - { - pathTarget = dirTargetArg.Value.TrimEnd('\\'); - } - parsedConfig.PathTargets.Add(pathTarget); - } - - //Console.WriteLine(parsedConfig.PathTargets[0]); - foreach (string pathTarget in parsedConfig.PathTargets) - { - Mq.Degub("Targeting path:" + pathTarget); - } - } - - if (maxGrepSizeArg.Parsed) - { - parsedConfig.MaxSizeToGrep = maxGrepSizeArg.Value; - Mq.Degub("We won't bother looking inside files if they're bigger than " + parsedConfig.MaxSizeToGrep + - " bytes"); - } - - if (snaffleSizeArg.Parsed) - { - parsedConfig.MaxSizeToSnaffle = snaffleSizeArg.Value; - } - - if (interestLevel.Parsed) - { - parsedConfig.InterestLevel = interestLevel.Value; - Mq.Degub("Requested interest level: " + parsedConfig.InterestLevel); - } - - // how many bytes - if (grepContextArg.Parsed) - { - parsedConfig.MatchContextBytes = grepContextArg.Value; - Mq.Degub( - "We'll show you " + grepContextArg.Value + - " bytes of context around matches inside files."); - } - - // if enabled, grab a copy of files that we like. - if (snaffleArg.Parsed) - { - if (snaffleArg.Value.Length <= 0) - { - Mq.Error("-m or -mirror arg requires a path value."); - throw new ArgumentException("Invalid argument combination."); - } - - parsedConfig.Snaffle = true; - parsedConfig.SnafflePath = snaffleArg.Value.TrimEnd('\\'); - Mq.Degub("Mirroring matched files to path " + parsedConfig.SnafflePath); - } - - if (configFileArg.Parsed) - { - if (configFileArg.Value.Equals("generate")) - { - Toml.WriteFile(parsedConfig, ".\\default.toml", settings); - Console.WriteLine("Wrote config values to .\\default.toml"); - parsedConfig.LogToConsole = true; - Mq.Degub("Enabled logging to stdout."); - return null; - } - else - { - string configFile = configFileArg.Value; - parsedConfig = Toml.ReadFile(configFile, settings); - Mq.Info("Read config file from " + configFile); - } - } - - if (!parsedConfig.LogToConsole && !parsedConfig.LogToFile) - { - Mq.Error( - "\nYou didn't enable output to file or to the console so you won't see any results or debugs or anything. Your l0ss."); - throw new ArgumentException("Pointless argument combination."); - } - - if (parsedConfig.ClassifierRules.Count <= 0) - { - if (String.IsNullOrWhiteSpace(parsedConfig.RuleDir)) - { - // get all the embedded toml file resources - string[] resourceNames = Assembly.GetExecutingAssembly().GetManifestResourceNames(); - StringBuilder sb = new StringBuilder(); - - foreach (string resourceName in resourceNames) - { - if (!resourceName.EndsWith(".toml")) - { - // skip this one as it's just metadata - continue; - } - string ruleFile = ReadResource(resourceName); - sb.AppendLine(ruleFile); - } - - string bulktoml = sb.ToString(); - - // deserialise the toml to an actual ruleset - RuleSet ruleSet = Toml.ReadString(bulktoml, settings); - - // stick the rules in our config! - parsedConfig.ClassifierRules = ruleSet.ClassifierRules; - } - else - { - string[] tomlfiles = Directory.GetFiles(parsedConfig.RuleDir, "*.toml", SearchOption.AllDirectories); - StringBuilder sb = new StringBuilder(); - foreach (string tomlfile in tomlfiles) - { - string tomlstring = File.ReadAllText(tomlfile); - sb.AppendLine(tomlstring); - } - string bulktoml = sb.ToString(); - // deserialise the toml to an actual ruleset - RuleSet ruleSet = Toml.ReadString(bulktoml, settings); - - // stick the rules in our config! - parsedConfig.ClassifierRules = ruleSet.ClassifierRules; - } - } - - parsedConfig.PrepareClassifiers(); - } - catch (Exception e) - { - Mq.Error(e.ToString()); - throw; - } - - return parsedConfig; - } - - - - } -} +using CommandLineParser.Arguments; +using Nett; +using NLog; +using SnaffCore.Concurrency; +using SnaffCore.Config; +using System; +using System.Resources; +using System.IO; +using System.Linq; +using System.Text; +using System.Reflection; +using System.Security; +using System.Collections.Generic; +using System.Net; + +namespace Snaffler +{ + public static class Config + { + public static Options Parse(string[] args) + { + BlockingMq Mq = BlockingMq.GetMq(); + Options options; + + // parse the args + try + { + options = ParseImpl(args); + if (options == null) + { + return null; + } + } + catch + { + Mq.Error("Something went wrong parsing args."); + throw; + } + + Mq.Info("Parsed args successfully."); + return options; + } + + + public static string ReadResource(string name) + { + // Determine path + var assembly = Assembly.GetExecutingAssembly(); + string resourcePath = name; + // Format: "{Namespace}.{Folder}.{filename}.{Extension}" + + using (Stream stream = assembly.GetManifestResourceStream(resourcePath)) + using (StreamReader reader = new StreamReader(stream)) + { + return reader.ReadToEnd(); + } + } + + public static bool isIP(string host) + { + IPAddress ip; + return IPAddress.TryParse(host, out ip); + } + + private static Options ParseImpl(string[] args) + { + BlockingMq Mq = BlockingMq.GetMq(); + Mq.Info("Parsing args..."); + Options parsedConfig = new Options(); + + // define args + ValueArgument configFileArg = new ValueArgument('z', "config", "Path to a .toml config file. Run with \'generate\' to puke a sample config file into the working directory."); + ValueArgument outFileArg = new ValueArgument('o', "outfile", + "Path for output file. You probably want this if you're not using -s."); + ValueArgument verboseArg = new ValueArgument('v', "verbosity", + "Controls verbosity level, options are Trace (most verbose), Debug (less verbose), Info (less verbose still, default), and Data (results only). e.g '-v debug' "); + SwitchArgument helpArg = new SwitchArgument('h', "help", "Displays this help.", false); + SwitchArgument stdOutArg = new SwitchArgument('s', "stdout", + "Enables outputting results to stdout as soon as they're found. You probably want this if you're not using -o.", + false); + ValueArgument interestLevel = new ValueArgument('b', "interest", "Interest level to report (0-3)"); + ValueArgument snaffleArg = new ValueArgument('m', "snaffle", + "Enables and assigns an output dir for Snaffler to automatically snaffle a copy of any found files."); + ValueArgument snaffleSizeArg = new ValueArgument('l', "snafflesize", "Maximum size of file to snaffle, in bytes. Defaults to 10MB."); + //var fileHuntArg = new SwitchArgument('f', "filehuntoff", + // "Disables file discovery, will only perform computer and share discovery.", false); + ValueArgument dirTargetArg = new ValueArgument('i', "dirtarget", + "Disables computer and share discovery, requires a path to a directory in which to perform file discovery."); + ValueArgument domainArg = new ValueArgument('d', "domain", + "Domain to search for computers to search for shares on to search for files in. Easy."); + ValueArgument domainControllerArg = new ValueArgument('c', "domaincontroller", + "Domain controller to query for a list of domain computers."); + ValueArgument maxGrepSizeArg = new ValueArgument('r', "maxgrepsize", + "The maximum size file (in bytes) to search inside for interesting strings. Defaults to 500k."); + ValueArgument grepContextArg = new ValueArgument('j', "grepcontext", + "How many bytes of context either side of found strings in files to show, e.g. -j 200"); + SwitchArgument domainUserArg = new SwitchArgument('u', "domainusers", "Makes Snaffler grab a list of interesting-looking accounts from the domain and uses them in searches.", false); + ValueArgument maxThreadsArg = new ValueArgument('x', "maxthreads", "How many threads to be snaffling with. Any less than 4 and you're gonna have a bad time."); + SwitchArgument tsvArg = new SwitchArgument('y', "tsv", "Makes Snaffler output as tsv.", false); + SwitchArgument dfsArg = new SwitchArgument('f', "dfs", "Limits Snaffler to finding file shares via DFS, for \"OPSEC\" reasons.", false); + SwitchArgument findSharesOnlyArg = new SwitchArgument('a', "sharesonly", + "Stops after finding shares, doesn't walk their filesystems.", false); + ValueArgument compExclusionArg = new ValueArgument('k', "exclusions", "Path to a file, or inline comma-separated list, of hostnames/IPs/CIDRs to exclude from scanning. e.g. -k exclusions.txt or -k 10.1.2.0/24,192.168.5.10"); + ValueArgument compTargetArg = new ValueArgument('n', "comptarget", "List of computers in a file(e.g C:\targets.txt), a single Computer (or comma separated list) to target."); + ValueArgument cidrTargetArg = new ValueArgument('q', "cidrtarget", "Comma-separated list of CIDRs to target. Queries AD for computers, then only scans those resolving within the specified CIDRs. e.g. -q 10.1.2.0/24,10.1.4.0/24"); + ValueArgument ruleDirArg = new ValueArgument('p', "rulespath", "Path to a directory full of toml-formatted rules. Snaffler will load all of these in place of the default ruleset."); + ValueArgument logType = new ValueArgument('t', "logtype", "Type of log you would like to output. Currently supported options are plain and JSON. Defaults to plain."); + ValueArgument timeOutArg = new ValueArgument('e', "timeout", + "Interval between status updates (in minutes) also acts as a timeout for AD data to be gathered via LDAP. Turn this knob up if you aren't getting any computers from AD when you run Snaffler through a proxy or other slow link. Default = 5"); + // list of letters i haven't used yet: gnw + + CommandLineParser.CommandLineParser parser = new CommandLineParser.CommandLineParser(); + parser.Arguments.Add(timeOutArg); + parser.Arguments.Add(configFileArg); + parser.Arguments.Add(outFileArg); + parser.Arguments.Add(helpArg); + parser.Arguments.Add(stdOutArg); + parser.Arguments.Add(snaffleArg); + parser.Arguments.Add(snaffleSizeArg); + parser.Arguments.Add(dirTargetArg); + parser.Arguments.Add(interestLevel); + parser.Arguments.Add(domainArg); + parser.Arguments.Add(verboseArg); + parser.Arguments.Add(domainControllerArg); + parser.Arguments.Add(maxGrepSizeArg); + parser.Arguments.Add(grepContextArg); + parser.Arguments.Add(domainUserArg); + parser.Arguments.Add(tsvArg); + parser.Arguments.Add(dfsArg); + parser.Arguments.Add(findSharesOnlyArg); + parser.Arguments.Add(maxThreadsArg); + parser.Arguments.Add(compTargetArg); + parser.Arguments.Add(ruleDirArg); + parser.Arguments.Add(logType); + parser.Arguments.Add(compExclusionArg); + parser.Arguments.Add(cidrTargetArg); + + // extra check to handle builtin behaviour from cmd line arg parser + if ((args.Contains("--help") || args.Contains("/?") || args.Contains("help") || args.Contains("-h") || args.Length == 0)) + { + parser.ShowUsage(); + return null; + } + + TomlSettings settings = TomlSettings.Create(cfg => cfg +.ConfigureType(tc => + tc.WithConversionFor(conv => conv + .FromToml(s => (LogLevel)Enum.Parse(typeof(LogLevel), s.Value, ignoreCase: true)) + .ToToml(e => e.ToString())))); + + try + { + parser.ParseCommandLine(args); + + if (timeOutArg.Parsed && !String.IsNullOrWhiteSpace(timeOutArg.Value)) + { + int timeOutVal; + if (int.TryParse(timeOutArg.Value, out timeOutVal)) + { + Mq.Info("Set timeout/update interval to " + timeOutVal.ToString() + " minutes."); + parsedConfig.TimeOut = timeOutVal; + } + else + { + Mq.Error("Invalid timeout value passed, defaulting to 5 mins."); + } + } + + if (logType.Parsed && !String.IsNullOrWhiteSpace(logType.Value)) + { + //Set the default to plain + parsedConfig.LogType = LogType.Plain; + //if they set a different type then replace it with the new type. + if (logType.Value.ToLower() == "json") + { + parsedConfig.LogType = LogType.JSON; + } + else + { + Mq.Info("Invalid type argument passed (" + logType.Value + ") defaulting to plaintext"); + } + } + + if (ruleDirArg.Parsed && !String.IsNullOrWhiteSpace(ruleDirArg.Value)) + { + parsedConfig.RuleDir = ruleDirArg.Value; + } + + // get the args into our config + + // output args + if (outFileArg.Parsed && (!String.IsNullOrEmpty(outFileArg.Value))) + { + parsedConfig.LogToFile = true; + parsedConfig.LogFilePath = outFileArg.Value; + Mq.Degub("Logging to file at " + parsedConfig.LogFilePath); + } + + if (dfsArg.Parsed) + { + parsedConfig.DfsOnly = dfsArg.Value; + } + if (compExclusionArg.Parsed) + { + List compExclusions = new List(); + IEnumerable entries; + + if (compExclusionArg.Value.Contains(',')) + { + entries = compExclusionArg.Value.Split(',') + .Select(l => l.Trim()) + .Where(l => !string.IsNullOrEmpty(l)); + } + else if (SnaffCore.NetworkUtils.CidrRegex.IsMatch(compExclusionArg.Value.Trim())) + { + entries = new[] { compExclusionArg.Value.Trim() }; + } + else if (compExclusionArg.Value.Contains(Path.DirectorySeparatorChar)) + { + entries = File.ReadAllLines(compExclusionArg.Value) + .Select(l => l.Trim()) + .Where(l => !string.IsNullOrEmpty(l)); + parsedConfig.ComputerExclusionFile = compExclusionArg.Value; + } + else + { + entries = new[] { compExclusionArg.Value.Trim() }; + } + + foreach (string entry in entries) + { + if (SnaffCore.NetworkUtils.CidrRegex.IsMatch(entry) || isIP(entry)) + { + compExclusions.Add(entry); + } + else + { + try + { + IPHostEntry result = Dns.GetHostEntry(entry); + foreach (IPAddress ipAddress in result.AddressList) + { + compExclusions.Add(ipAddress.ToString()); + } + } + catch (Exception ex) + { + Console.WriteLine(ex.Message); + continue; + } + } + } + + if (compExclusions.Count > 0) + { + parsedConfig.ComputerExclusions = compExclusions; + var cidrs = compExclusions.Where(e => SnaffCore.NetworkUtils.CidrRegex.IsMatch(e)).ToList(); + var ips = compExclusions.Where(e => !SnaffCore.NetworkUtils.CidrRegex.IsMatch(e)).ToList(); + if (cidrs.Count > 0) Mq.Info("CIDR exclusions active: " + string.Join(", ", cidrs)); + if (ips.Count > 0) Mq.Info("Excluding " + ips.Count + " host(s) by IP."); + } + else + { + throw new Exception("Failed to get a valid list of excluded computers from the excluded computers list."); + } + } + + if (cidrTargetArg.Parsed) + { + parsedConfig.CidrInclusions = cidrTargetArg.Value.Split(',') + .Select(l => l.Trim()) + .Where(l => SnaffCore.NetworkUtils.CidrRegex.IsMatch(l)) + .ToList(); + + if (parsedConfig.CidrInclusions.Count == 0) + { + throw new Exception("No valid CIDRs found in -q argument. Expected format: 10.1.2.0/24"); + } + + Mq.Info("CIDR target filter active: " + string.Join(", ", parsedConfig.CidrInclusions)); + } + + if (compTargetArg.Parsed) + { + List compTargets = new List(); + if (compTargetArg.Value.Contains(Path.DirectorySeparatorChar)) + { + compTargets.AddRange(File.ReadLines(compTargetArg.Value).Select(line => line.Trim())); + } + else if (compTargetArg.Value.Contains(",")) + { + compTargets.AddRange(compTargetArg.Value.Split(',').Select(x => x.Trim())); + } + else + { + compTargets.Add(compTargetArg.Value.Trim()); + } + parsedConfig.ComputerTargets = compTargets.ToArray(); + } + + if (findSharesOnlyArg.Parsed) + { + parsedConfig.ScanFoundShares = false; + } + if (maxThreadsArg.Parsed) + { + parsedConfig.MaxThreads = maxThreadsArg.Value; + } + + parsedConfig.ShareThreads = parsedConfig.MaxThreads / 3; + parsedConfig.FileThreads = parsedConfig.MaxThreads / 3; + parsedConfig.TreeThreads = parsedConfig.MaxThreads / 3; + + if (tsvArg.Parsed) + { + parsedConfig.LogTSV = true; + if (parsedConfig.Separator == ' ') + { + parsedConfig.Separator = '\t'; + } + } + + // Set loglevel. + if (verboseArg.Parsed) + { + parsedConfig.LogLevelString = verboseArg.Value; + Mq.Degub("Requested verbosity level: " + parsedConfig.LogLevelString); + } + + // if enabled, display findings to the console + parsedConfig.LogToConsole = stdOutArg.Parsed; + Mq.Degub("Enabled logging to stdout."); + + // args that tell us about targeting + if ((domainArg.Parsed) && (!String.IsNullOrEmpty(domainArg.Value))) + { + parsedConfig.TargetDomain = domainArg.Value; + Mq.Degub("Target domain is " + domainArg.Value); + } + + if ((domainControllerArg.Parsed) && (!String.IsNullOrEmpty(domainControllerArg.Value))) + { + parsedConfig.TargetDc = domainControllerArg.Value; + Mq.Degub("Target DC is " + domainControllerArg.Value); + } + + if (domainUserArg.Parsed) + { + parsedConfig.DomainUserRules = true; + Mq.Degub("Enabled use of domain user accounts in rules."); + } + + if (dirTargetArg.Parsed) + { + parsedConfig.ShareFinderEnabled = false; + Mq.Degub("Disabled finding shares."); + + if (dirTargetArg.Value.Contains(",")) + { + string pathTargetsString = dirTargetArg.Value; + string[] pathTargets = pathTargetsString.Split(','); + foreach (string pathTarget in pathTargets) + { + if (pathTarget.Length > 4) + { + parsedConfig.PathTargets.Add(pathTarget.TrimEnd('\\')); + } + else + { + parsedConfig.PathTargets.Add(pathTarget); + } + } + } + else + { + string pathTarget = dirTargetArg.Value; + if (dirTargetArg.Value.Length > 4) + { + pathTarget = dirTargetArg.Value.TrimEnd('\\'); + } + parsedConfig.PathTargets.Add(pathTarget); + } + + //Console.WriteLine(parsedConfig.PathTargets[0]); + foreach (string pathTarget in parsedConfig.PathTargets) + { + Mq.Degub("Targeting path:" + pathTarget); + } + } + + if (maxGrepSizeArg.Parsed) + { + parsedConfig.MaxSizeToGrep = maxGrepSizeArg.Value; + Mq.Degub("We won't bother looking inside files if they're bigger than " + parsedConfig.MaxSizeToGrep + + " bytes"); + } + + if (snaffleSizeArg.Parsed) + { + parsedConfig.MaxSizeToSnaffle = snaffleSizeArg.Value; + } + + if (interestLevel.Parsed) + { + parsedConfig.InterestLevel = interestLevel.Value; + Mq.Degub("Requested interest level: " + parsedConfig.InterestLevel); + } + + // how many bytes + if (grepContextArg.Parsed) + { + parsedConfig.MatchContextBytes = grepContextArg.Value; + Mq.Degub( + "We'll show you " + grepContextArg.Value + + " bytes of context around matches inside files."); + } + + // if enabled, grab a copy of files that we like. + if (snaffleArg.Parsed) + { + if (snaffleArg.Value.Length <= 0) + { + Mq.Error("-m or -mirror arg requires a path value."); + throw new ArgumentException("Invalid argument combination."); + } + + parsedConfig.Snaffle = true; + parsedConfig.SnafflePath = snaffleArg.Value.TrimEnd('\\'); + Mq.Degub("Mirroring matched files to path " + parsedConfig.SnafflePath); + } + + if (configFileArg.Parsed) + { + if (configFileArg.Value.Equals("generate")) + { + Toml.WriteFile(parsedConfig, ".\\default.toml", settings); + Console.WriteLine("Wrote config values to .\\default.toml"); + parsedConfig.LogToConsole = true; + Mq.Degub("Enabled logging to stdout."); + return null; + } + else + { + string configFile = configFileArg.Value; + parsedConfig = Toml.ReadFile(configFile, settings); + Mq.Info("Read config file from " + configFile); + } + } + + if (!parsedConfig.LogToConsole && !parsedConfig.LogToFile) + { + Mq.Error( + "\nYou didn't enable output to file or to the console so you won't see any results or debugs or anything. Your l0ss."); + throw new ArgumentException("Pointless argument combination."); + } + + if (parsedConfig.ClassifierRules.Count <= 0) + { + if (String.IsNullOrWhiteSpace(parsedConfig.RuleDir)) + { + // get all the embedded toml file resources + string[] resourceNames = Assembly.GetExecutingAssembly().GetManifestResourceNames(); + StringBuilder sb = new StringBuilder(); + + foreach (string resourceName in resourceNames) + { + if (!resourceName.EndsWith(".toml")) + { + // skip this one as it's just metadata + continue; + } + string ruleFile = ReadResource(resourceName); + sb.AppendLine(ruleFile); + } + + string bulktoml = sb.ToString(); + + // deserialise the toml to an actual ruleset + RuleSet ruleSet = Toml.ReadString(bulktoml, settings); + + // stick the rules in our config! + parsedConfig.ClassifierRules = ruleSet.ClassifierRules; + } + else + { + string[] tomlfiles = Directory.GetFiles(parsedConfig.RuleDir, "*.toml", SearchOption.AllDirectories); + StringBuilder sb = new StringBuilder(); + foreach (string tomlfile in tomlfiles) + { + string tomlstring = File.ReadAllText(tomlfile); + sb.AppendLine(tomlstring); + } + string bulktoml = sb.ToString(); + // deserialise the toml to an actual ruleset + RuleSet ruleSet = Toml.ReadString(bulktoml, settings); + + // stick the rules in our config! + parsedConfig.ClassifierRules = ruleSet.ClassifierRules; + } + } + + parsedConfig.PrepareClassifiers(); + } + catch (Exception e) + { + Mq.Error(e.ToString()); + throw; + } + + return parsedConfig; + } + + + + } +} diff --git a/Tests/CidrTest.cs b/Tests/CidrTest.cs new file mode 100644 index 00000000..f1ad2e52 --- /dev/null +++ b/Tests/CidrTest.cs @@ -0,0 +1,87 @@ +// Test harness for CIDR exclusion logic in SnaffCore.NetworkUtils. +// Compile: csc /reference:SnaffCore.dll Tests\CidrTest.cs +// Run: CidrTest.exe +using System; +using System.Net; +using SnaffCore; + +class CidrTest +{ + static int passed = 0; + static int failed = 0; + + static void Main() + { + // --- IPv4 standard cases --- + Test("10.1.2.100", "10.1.2.0/24", true, "IPv4 host inside /24"); + Test("10.1.3.1", "10.1.2.0/24", false, "IPv4 host outside /24"); + Test("10.5.6.7", "10.0.0.0/8", true, "IPv4 host inside /8"); + Test("11.0.0.1", "10.0.0.0/8", false, "IPv4 host outside /8"); + Test("172.16.5.5", "172.16.0.0/16", true, "IPv4 host inside /16"); + Test("172.17.0.1", "172.16.0.0/16", false, "IPv4 host outside /16"); + + // --- Boundary cases --- + Test("10.1.2.0", "10.1.2.0/24", true, "Network address itself"); + Test("10.1.2.255", "10.1.2.0/24", true, "Broadcast address"); + Test("10.1.3.0", "10.1.2.0/24", false, "First address of next network"); + + // --- Non-byte-boundary prefix (/20, /25 etc.) --- + Test("10.1.16.1", "10.1.16.0/20", true, "IPv4 host inside /20"); + Test("10.1.32.1", "10.1.16.0/20", false, "IPv4 host outside /20"); + Test("192.168.1.1", "192.168.1.0/25", true, "IPv4 host inside /25 lower half"); + Test("192.168.1.128","192.168.1.0/25", false, "IPv4 host in upper half of /25"); + + // --- /32 single host --- + Test("192.168.1.1", "192.168.1.1/32", true, "/32 exact match"); + Test("192.168.1.2", "192.168.1.1/32", false, "/32 non-match"); + + // --- /0 match everything --- + Test("1.2.3.4", "0.0.0.0/0", true, "IPv4 /0 matches any address"); + Test("255.255.255.255","0.0.0.0/0", true, "IPv4 /0 matches broadcast"); + + // --- IPv6 --- + Test("2001:db8::1", "2001:db8::/32", true, "IPv6 host inside /32"); + Test("2001:db9::1", "2001:db8::/32", false, "IPv6 host outside /32"); + Test("fe80::1", "fe80::/10", true, "IPv6 link-local inside /10"); + Test("fe40::1", "fe80::/10", false, "IPv6 outside link-local /10"); + + // --- Mixed family (should never match) --- + Test("10.1.2.1", "2001:db8::/32", false, "IPv4 address vs IPv6 CIDR"); + Test("2001:db8::1", "10.1.2.0/24", false, "IPv6 address vs IPv4 CIDR"); + + // --- Malformed input (should not throw, just return false) --- + TestMalformed("notacidr", "Malformed: no slash"); + TestMalformed("10.1.2.0/abc", "Malformed: non-numeric prefix"); + TestMalformed("999.999.999.999/24","Malformed: invalid network address"); + TestMalformed("/24", "Malformed: missing network address"); + + Console.WriteLine(); + Console.WriteLine($"Results: {passed} passed, {failed} failed"); + Environment.Exit(failed > 0 ? 1 : 0); + } + + static void Test(string ip, string cidr, bool expected, string label) + { + IPAddress address = IPAddress.Parse(ip); + bool result = NetworkUtils.IsInCidr(address, cidr); + bool ok = result == expected; + if (ok) passed++; else failed++; + Console.WriteLine($"[{(ok ? "PASS" : "FAIL")}] {label}: {ip} in {cidr} => {result} (expected {expected})"); + } + + static void TestMalformed(string cidr, string label) + { + try + { + bool result = NetworkUtils.IsInCidr(IPAddress.Parse("10.1.2.1"), cidr); + bool ok = result == false; + if (ok) passed++; else failed++; + Console.WriteLine($"[{(ok ? "PASS" : "FAIL")}] {label}: returned {result} (expected false)"); + } + catch (Exception ex) + { + failed++; + Console.WriteLine($"[FAIL] {label}: threw exception: {ex.Message}"); + } + } +}