I have a tiny server, sitting in the cloud, running cygwin’s OpenSSH. Logging in requires an SSH key, but this doesn't stop people from trying all sorts of ways to get in.
Normally, this does nothing whatsoever, other than fill up my event logs with “invalid user” messages. However, I thought it might be nice to filter these users out at the firewall level. That’s what this program does: it monitors the event log for invalid SSH connection attempts, and adds the offending IP to the Windows Firewall list of blocked IP addresses.
Since my server is named Bifröst, I've named this program Heimdall. Heimdall is designed to be run from the command line, or as a scheduled task. While Heimdall is not a terribly complex program, I did learn a few things as I wrote it. This post is both to document what I found, and to serve as a reminder to me, should I ever need this information again.
The first thing Heimdall does is scan through the event log, looking for events matching a specific pattern. This is done using the EventLogReader and EventLogQuery classes. In my case, the relevant code followed this pseudo-code:
public static IEnumerable<EventEntry> GetEvents(int entriesToScan) { const string queryString = "*[System[Provider[@Name='sshd'] and EventID=0]]"; var eventsQuery = new EventLogQuery("Application", PathType.LogName, queryString) { ReverseDirection = true }; // The "EventEntry" class is just a model for holding information about this // a single event. Keep reading for further details. var events = new List<EventEntry>(); entriesToScan = Math.Max(entriesToScan, 1); try { using (var logReader = new EventLogReader(eventsQuery)) { EventRecord eventInstance; int currentEvent; for ( eventInstance = logReader.ReadEvent(), currentEvent = 1; eventInstance != null && currentEvent <= entriesToScan; eventInstance = logReader.ReadEvent(), currentEvent += 1) { EventEntry entry; try { entry = EventEntry.From(eventInstance); } finally { eventInstance.Dispose(); } if (entry != null) { events.Add(entry); } } eventInstance?.Dispose(); } } catch (EventLogNotFoundException e) { Console.Write("Failed to query the log!", e); return null; } return events; }
I learned that the event log messages emitted from logReader.ReadEvent() implement IDisposable and should be disposed of.
I did not find any way to limit the number of items returned by the query, other than manually counting them. Since the every query looks like a standard xpath query, I tried experimenting with position()
, but I could not make it work.
In my case, I needed information out of the EventData
section of the event object. I could not find a way to access this information using any of the conveinance methods, but fortunately, the complete event information is available as XML. I was able to access the EventData
by parsing the XML from the EventRecord
object:
public static string GetData(EventRecord eventInstance) { const string namespaceName = "http://schemas.microsoft.com/win/2004/08/events/event"; var eventDataName = XName.Get("EventData", namespaceName); var dataName = XName.Get("Data", namespaceName); return XDocument.Parse(eventInstance.ToXml()) .Descendants(eventDataName).FirstOrDefault()? .Descendants(dataName).FirstOrDefault()? .Value; }
Once I had the event data, I was able to parse it using a regular expression, looking for a username and IP address. The username gets compared to a white-list of allowed usernames, and the IP address is checked to make sure it isn't coming from a private block. This is partially for my own protection: I figure that if I ever accidentally lock myself out, I can always try again from a private IP address.
If the attempted username is not on the white-list, and if the IP address is not private, then Heimdall adds the IP address to the list of addresses blocked by the Windows Firewall. Working with the Windows Firewall is done by way of the INetFwPolicy2
interface.
For the purposes of this program, I make no attempt to create new firewall rules. Instead, I modify an existing Firewall rule, which I manually created beforehand. A useful additional to Heimdall would be the ability to create its own rule, to reduce the amount of manual configuration necessary. However, as of right now, Heimdall assumes that this rule already exists:
This is a simple blocking rule, which Heimdall is able to access by name:
private static INetFwRule GetBlockingRule() => ((INetFwPolicy2) Activator.CreateInstance(Type.GetTypeFromProgID("HNetCfg.FwPolicy2"))) .Rules .OfType<INetFwRule>() .FirstOrDefault(r => r.Name == "Block Specific IPs");
Once we have a reference to the firewall rule, we just need to add the target IP address to the list of remote addresses affected by this rule.
I took some care to handle ranges of IP addresses; that is, if two or more adjacent IP addresses are blocked, they are added to the firewall as a range, instead of individual entries. I found the IPAddressRange project to be very useful in assisting with this.
public static void BlockIp(IPAddress ip) { var rule = GetBlockingRule(); var addresses = rule.RemoteAddresses .Split(',') .Where(s => string.IsNullOrWhiteSpace(s) == false) .Select(IPAddressRange.Parse) .ToList(); addresses.Add(new IPAddressRange(ip)); // Details of ConsolidateRanges are not relevant to this post; check the // source on BitBucket if you are curious. addresses = ConsolidateRanges(addresses); rule.RemoteAddresses = string.Join(",", addresses.Select(r => r.ToString())); }
Finally, Heimdall sends me an email whenever a new IP address is blocked. I've had this program running for about a week now, and I've enjoyed seeing some of the creative usernames that people try to log in with. Over the past week alone, there have been 35 separate IP addresses blocked, which have attempted 205 unique usernames.
If you are curious enough to have read this far, you may be interested in seeing the source code for Heimdall!