服务只要发布到公网,一定会被试探、暴力破解甚至漏洞利用,从无例外,所以建立自己的一套防御策略尤为重要。这次试从几个常见服务入手,探讨一下攻击威胁的分析和防御实现。

Windows远程桌面(RDP)

Windows远程桌面登录失败时,会在安全日志中留下记录(用事件查看器打开“windows日志” -> “安全”类目进行查看)。

如果服务器的远程桌面开放在外网,将会在日志中看到大量的登录失败尝试(可以按事件ID=4625进行筛选,登录失败的ID为4625,登录成功的ID是4624)。

1. 防护依据

进行防护的最重要凭据就是登录日志:

  • 检索失败登录的记录,获取IP,失败计数+1;
  • 检索到登录成功的记录,获取IP,清零失败计数;
  • 失败次数达到阈值,封禁IP。

大致思路是这样,具体实现就按自己的喜好来了。

2. 操作windows防火墙

防火墙的操作需求可以通过调用系统COM组件HNetCfg.FwPolicy2(需要加载NetFwTypeLib)来实现,本项目需要用到的功能有:读取、创建和编辑防火墙策略,在进行封禁的时候,用以将指定IP添加到拒绝策略中。

先看一个演示读取、创建、修改动作的DEMO:

INetFwPolicy2 policy2 =
                (INetFwPolicy2)Activator.CreateInstance(
                    Type.GetTypeFromProgID("HNetCfg.FwPolicy2"));

            //查看当前防火墙策略集里有没有"RDP_FORBIDDEN"开头的策略
            IEnumerable<INetFwRule> rules =
                policy2.Rules.Cast<INetFwRule>().Where(
                    r => r.Name.ToUpper().StartsWith("RDP_FORBIDDEN"))
                .OrderBy(r => r.Name);

            //如果存在,把两个IP添到最后一条策略里,并打印完整的IP列表
            if (rules.Count() > 0)
            {
                var rule = rules.Last();
                rule.RemoteAddresses += ",1.1.1.1,2.2.2.2";
                Console.WriteLine(rule.RemoteAddresses.Substring(0, 100));
            }
            else
            {
                Console.WriteLine("没有RDP_FORBIDDEN开头的策略");
                //创建一条名为RDP_FORBIDDEN_n 的策略 n= rules.Count()
                //编号从0开始,每次+1,确保每次创建都不会重名
                //禁止 RemoteAddresses 列表中的IP访问本机LocalPorts端口
                INetFwRule rule =
                      (INetFwRule)Activator.CreateInstance(
                          Type.GetTypeFromProgID("HNetCfg.FwRule"));
                rule.Name = $"RDP_FORBIDDEN_{rules.Count().ToString("0000")}";
                rule.RemoteAddresses = "1.1.1.1,2.2.2.2";
                rule.Protocol = (int)NET_FW_IP_PROTOCOL_.NET_FW_IP_PROTOCOL_TCP;
                rule.LocalPorts = "3389";
                rule.Description = "RDP Attackers Blocked By Monitor.";
                rule.Direction = NET_FW_RULE_DIRECTION_.NET_FW_RULE_DIR_IN;
                rule.Action = NET_FW_ACTION_.NET_FW_ACTION_BLOCK;
                rule.Enabled = true;
                policy2.Rules.Add(rule);
                Console.WriteLine($"新策略{rule.Name}添加完成");
            }
            Console.Read();

操作防火墙的部分,只需要用到上面这些功能即可,但是实现完整功能需要做的事远不止这些。如何对登录尝试进行甄别和记录:哪个IP,在什么时间有一次失败的登录?在一定的周期内,它登录了几次?要记录这些信息,我们就需要准备一个全局的威胁名单,并实时进行维护。


class Attacker
        {
            public static readonly long LogDays = 60;
            public static readonly int Threshold = 5;
            public string IP { get; set; }

            public (long? id, DateTime? time) GetLast()
            {
                try
                {
                    return Attacks.OrderBy(a => a.time).Last();
                }
                catch (Exception)
                {
                    return (null, null);
                }
            }

            public List<(long? id, DateTime? time)> Attacks =
                new List<(long? id, DateTime? time)>();

            public void Clear() => Attacks.Clear();
            /// <summary>
            /// 添加失败记录,清理过期记录,返回是否触线
            /// </summary>
            /// <param name="dateTime"></param>
            /// <returns></returns>
            public bool Add(long? id, DateTime? dateTime)
            {
                Attacks.Add((id, dateTime));
                Attacks =
                    Attacks.Where(a =>
                    a.time > DateTime.Now.AddDays(-LogDays))
                    .ToList();
                return Attacks.Count() > Threshold;
            }
            public Attacker(string ip)
            {
                IP = ip +
                    (!ip.Contains("/") ? "/255.255.255.255" : "");
            }
        }

3. 检索系统日志

软件的日志数据来自 windows日志 -> 安全 ,需要用到System.Diagnostics命名空间下的EventLog对象。

EventLog:系统日志对象(或者按“目录”的概念去理解也可以),它包含了本日志类目下所有的日志记录(即EventLogEntry),如果想要获取所有日志记录,只需要定位到日志对象(本项目为 Security),获取其Entries属性即可得到日志记录的集合。

EventLogEntry:日志记录对象,每一类记录都有自己独有的InstanceId(例如,登录失败记录的InstanceId为4625,登录成功的记录为4624……),记录的各个字段保存在ReplacementStrings数组中(不同的记录类型拥有不同的字段,例如登录成功记录的IP地址保存在ReplacementString[18],登录失败记录的IP地址保存在ReplacementString[19])。

以下代码演示了系统日志读取,取出最新的一条失败登录记录,输出IP地址:

            EventLog SecurityLog =
                EventLog.GetEventLogs().Where(
                    entry => entry.Log == "Security").First();

            Console.WriteLine(
                SecurityLog.Entries.Cast<EventLogEntry>().
                    Where(entry =>
                      entry.InstanceId == 4625 && 
                      entry.ReplacementStrings[19] != "-")
                    .OrderByDescending(entry => entry.TimeGenerated)
                    .First().ReplacementStrings[19]);

完整读取日志列表 -> 找出登录失败的记录 -> 统计失败次数 -> 封禁,看上去这个流程达到了目的,然而还需要面对几个问题:

  • 每时每刻都可能有人在尝试暴力登录,日志不断产生,新的失败登录怎么才能统计进来?
  • 错误记录需要保存多久?所有的失败登录都需要永远统计进来吗?
  • 如果因为一些意外的输入错误,导致正常的用户连续输错密码应该怎么进行豁免?
  • 防火墙的作用域可保存的IP数量存在上限,到达上限后应该如何处理?

4. 更高效的统计方式

针对问题a,尽管可以用重复遍历所有日志的方式统计新的事件,但这并不能算是一个合格的解决方案。无限循环进行遍历,必然带来更高的资源消耗、更长的处理时间,相比之下,订阅事件通知是更高效的做法(System.Diagnostics.Eventing.Reader.EventLogWatcher)。

想接受事件通知,只需要实例化一个EventLogWatcher即可启动订阅,当有日志写入时,会触发EventRecordWritten事件。

static void StartWatcher()
{
    WriteLog("启动日志实时监控.");
    using EventLogWatcher logWatcher =
        new EventLogWatcher(Query);
    logWatcher.EventRecordWritten += (o, arg) =>
    {
        var log = arg.EventRecord;
        bool succ = log.Id == 4624;
        string ip = 
            log.Properties[succ ? 18 : 19].Value.ToString();
        DateTime? recTime = log.TimeCreated;
        WriteLog($"检测到{(succ ? "成功" : "失败")}登录:{ip}");
        ProcessRecord(ip, (log.RecordId, recTime, false));
    };
    logWatcher.Enabled = true;
}

5. 更完善的防火墙策略更新逻辑

针对后三个问题的解决方式是:

  • 登录成功后清空失败记录;
  • 删除时间超长的登录记录;
  • 封禁前按防火墙名单去重。
/// <summary>
/// 处理一条成功的或者失败的登录记录
/// </summary>
/// <param name="ip">ip地址</param>
/// <param name="rec">元组:记录id,记录时间,是否成功</param>
static void ProcessRecord(string ip,
                (long? id, DateTime? time, bool result) rec)
{
    //
    if (Exemption.Any(e => ip.StartsWith(e)))
    {
        WriteLog($"IP {ip} 处于豁免列表,无需处理。");
        return;
    }    
    
    //如果是一条成功记录
    //如果已经存在IP记录,清空失败记录;新IP,无需处理
    if (rec.result)
    {
        if (Attackers.ContainsKey(ip))
        {
            WriteLog($"IP {ip}成功登录,已清空失败记录。");
            Attackers[ip].Clear();
        }
    }
    else
    {
        //失败记录,如果已经存在IP记录,失败计数+1
        //如果不存在,创建新的对象
        if (Attackers.ContainsKey(ip))
        {
            Attacker attacker = Attackers[ip];
            if (!attacker.Attacks.Any(a => a.id == rec.id))
            {
                if (attacker.Add(rec.id, rec.time))
                {
                    Block(new List<string>() { ip });
                    WriteLog($"IP {ip}登录失败次数超限,已封禁。");
                }
                else
                {
                    WriteLog($"IP {ip}登录失败:第 " +
                        $"{attacker.Attacks.Count}次。");
                }
            }
        }
        else
        {
            Attacker attacker = new Attacker(ip)
            {
                Attacks =
                new List<(long?, DateTime?)>() {
                                (rec.id, rec.time)
                }
            };
            Attackers[ip] = attacker;
        }
    }
}
/// <summary>
/// 添加失败记录,清理过期记录,返回是否触线
/// </summary>
/// <param name="dateTime"></param>
/// <returns></returns>
public bool Add(long? id, DateTime? dateTime)
{
    Attacks.Add((id, dateTime));
    Attacks =
        Attacks.Where(a =>
            a.time > DateTime.Now.AddHours(-LogHours))
        .ToList();
    return Attacks.Count() > Threshold;
}
//与自己去重,与BlackList去重
foreach (string add in addresses)
{
    Attacker blkItem =
        Attackers.Values.Where(a => a.IP == add).FirstOrDefault();
    if (blkItem != null) Attackers.Remove(blkItem.IP);
}
List<string> finalAddresses =
    addresses.Distinct().Except(BlackList).ToList();

6. 最终效果:

运行后立即开启日志监控,同时全盘扫描历史记录,对于豁免名单中的IP不做处理;威胁名单中的IP达到阈值就屏蔽。同时也提供了几条简单的交互,供查询当前威胁列表、封禁记录,以及防火墙规则列表。

完整代码:

using NetFwTypeLib;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Diagnostics.Eventing.Reader;
using System.Linq;
using System.Text.RegularExpressions;

namespace rdp_defender_console
{
    class Program
    {
        const string EventQueryString =
            "*[EventData[Data[@Name='IpAddress'] != '-'] " +
            "and System[(EventID='4625' or EventID='4624')]]";
        const string CmdHelp =
            "\r\n========================================" +
            "\r\nhelp: 帮助\r\n1:威胁记录\r\n" +
            "2:封禁记录\r\n3:防火墙策略列表\r\nexit: 退出\r\n>";
        static readonly List<string> Exemption =
            new List<string>() { "192.168", "127.0.0.1" };

        static List<string> BlackList = new List<string>();

        /// <summary>
        /// 潜在威胁名单,失败次数达到阈值即封禁
        /// </summary>
        static readonly Dictionary<string, Attacker> Attackers =
            new Dictionary<string, Attacker>();

        static INetFwPolicy2 Policy2;

        static void Main()
        {
            Policy2 =
                (INetFwPolicy2)Activator.CreateInstance(
                    Type.GetTypeFromProgID("HNetCfg.FwPolicy2"));
            WriteLog($"封禁阈值:{Attacker.Threshold} 次失败登录", false);
            WriteLog($"追溯历史:{Attacker.LogDays} 天", false);
            WriteLog("初始化历史黑名单");

            RefreshIPs();//以当前防火墙中的黑名单初始化BlackList列表


            WriteLog("启动日志实时监控.");
            using EventLogWatcher logWatcher =
                new EventLogWatcher(
                    new EventLogQuery("Security",
                    PathType.LogName, EventQueryString));
            logWatcher.EventRecordWritten += (o, arg) =>
            {
                var log = arg.EventRecord;
                bool succ = log.Id == 4624;
                string ip =
                log.Properties[succ ? 18 : 19].Value.ToString();
                DateTime? recTime = log.TimeCreated;
                ProcessRecord(ip, (log.RecordId, recTime, false));
            };
            logWatcher.Enabled = true;


            var logEntries =
                EventLog.GetEventLogs().Where(
                    entry => entry.Log == "Security").First()
                    .Entries.Cast<EventLogEntry>().Where(entry =>
                    (entry.InstanceId == 4625 &&
                    entry.ReplacementStrings[19] != "-") || (
                    entry.InstanceId == 4624 &&
                    entry.ReplacementStrings[18] != "-"))
                    .OrderBy(entry => entry.TimeGenerated);
            WriteLog("扫描历史记录");
            foreach (EventLogEntry logEntry in logEntries)
            {
                string ip =
                    logEntry.ReplacementStrings[
                        logEntry.InstanceId == 4625 ? 19 : 18]
                    + "/255.255.255.255";
                ProcessRecord(ip,
                    (logEntry.Index,
                    logEntry.TimeGenerated,
                    logEntry.InstanceId == 4625));
            }
            WriteLog($"扫描历史记录完成\r\n{CmdHelp}", true, false);

            while (true)
            {
                string msg = Console.ReadLine().ToLower() switch
                {
                    "1" =>
                    "\t" + string.Join(
                        "\r\n\t",
                        Attackers.Values.Where(v => v.Attacks.Count > 0)
                        .Select(
                            v =>
                            $"IP: {Sip(v.IP)}\t次数:{v.Attacks.Count}, " +
                            $"最新记录:{v.GetLast().time}")),

                    "2" =>
                    $"共有{BlackList.Count}条:\r\n\t" +
                    $"{string.Join("\r\n\t", BlackList)}",

                    "3" => "\t" + string.Join(
                        "\r\n\t", Rules().Select(r => r.Name)),

                    "exit" => "exit",

                    _ => ""
                };
                if (msg == "exit") Environment.Exit(0);
                WriteLog(msg + CmdHelp, false, false);
            }
        }
        static void WriteLog(
            string msg,
            bool datePrefix = true,
            bool newLine = true)
        {
            if (datePrefix) Console.Write(
                DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss\t"));
            Console.Write(msg + (newLine ? "\r\n" : ""));
        }

        static string Sip(string ip) =>
            ip.Replace("/255.255.255.255", "");

        #region 日志部分

        static void ProcessRecord(string ip,
            (long? id, DateTime? time, bool result) rec)
        {
            //豁免列表,无需处理
            if (Exemption.Any(e => ip.StartsWith(e)))
            {
                WriteLog($"\tIP {Sip(ip)}\t豁免跳过。", false);
                return;
            }

            //如果是一条成功记录
            //如果已经存在IP记录,清空失败记录;新IP,无需处理
            if (rec.result)
            {
                if (Attackers.ContainsKey(ip))
                {
                    WriteLog($"\tIP {Sip(ip)}\t成功登录,已清空失败记录。", false);
                    Attackers[ip].Clear();
                }
            }
            else
            {
                //失败记录,如果已经存在IP记录,失败计数+1
                //如果不存在,创建新的对象
                if (Attackers.ContainsKey(ip))
                {
                    Attacker attacker = Attackers[ip];
                    if (!attacker.Attacks.Any(a => a.id == rec.id))
                    {
                        if (attacker.Add(rec.id, rec.time))
                        {
                            Block(new List<string>() { ip });
                            WriteLog($"\tIP {Sip(ip)}\t{rec.time}" +
                                $"登录失败次数超限,已封禁。", false);
                        }
                        else
                        {
                            WriteLog($"\tIP {Sip(ip)}\t{rec.time}登录失败:有效次数 " +
                                $"{attacker.Attacks.Count}。", false);
                        }
                    }
                }
                else
                {
                    Attacker attacker = new Attacker(ip)
                    {
                        Attacks =
                        new List<(long?, DateTime?)>() {
                                (rec.id, rec.time)
                        }
                    };
                    Attackers[ip] = attacker;
                }
            }
        }
        #endregion

        #region 防火墙部分
        /// <summary>
        /// 汇总所有防护策略的屏蔽IP
        /// </summary>
        static void RefreshIPs()
        {
            Rules().ForEach(r =>
            Regex.Matches(r.RemoteAddresses, "[0-9./]+")
            .Cast<Match>().ToList().ForEach(
                m => BlackList.Add(m.Value)));
            BlackList = BlackList.Distinct().ToList();
        }

        static List<INetFwRule> Rules() =>
            Policy2.Rules.Cast<INetFwRule>().Where(
                r => r.Name.ToUpper().StartsWith("RDP_FORBIDDEN"))
            .OrderBy(r => r.Name).ToList();

        /// <summary>
        /// 封禁指定IP
        /// </summary>
        /// <param name="addresses">以逗号分隔的IP列表,
        /// 可不带掩码(等同于/32)</param>
        static void Block(List<string> addresses)
        {
            //与自己去重,与BlackList去重
            foreach (string add in addresses)
            {
                Attacker blkItem =
                    Attackers.Values.Where(a => a.IP == add).FirstOrDefault();
                if (blkItem != null) Attackers.Remove(blkItem.IP);
            }
            List<string> finalAddresses =
                addresses.Distinct().Except(BlackList).ToList();
            try
            {
                INetFwRule rule = Rules().Last();
                rule.RemoteAddresses += $",{string.Join(",", finalAddresses)}";
            }
            catch (Exception ex)
            {
                string msg = ex.ToString();
                //无论是IP到达上限导致失败,还是由于不存在这个规则而
                //导致空指针异常,都在异常捕获时直接创建新的规则。
                Create3389Rule(finalAddresses);
            }
        }

        /// <summary>
        /// 创建一条封禁tcp3389的策略
        /// </summary>
        /// <param name="addresses">以逗号分隔的IP列表,
        /// 可不带掩码(等同于/32)</param>
        static void Create3389Rule(List<string> addresses)
        {
            //创建一条名为RDP_FORBIDDEN_n 的策略 n= rules.Count()
            //编号从0开始,每次+1,确保每次创建都不会重名(虽然windows防
            //火墙允许策略重名,但是为了方便管理,确保名字不重复为宜);
            //禁止 RemoteAddresses 列表中的IP访问本机LocalPorts端口
            INetFwRule rule =
                 (INetFwRule)Activator.CreateInstance(
                     Type.GetTypeFromProgID("HNetCfg.FwRule"));
            rule.Name = $"RDP_FORBIDDEN_0000";
            rule.RemoteAddresses = string.Join(",", addresses);
            rule.Protocol = (int)NET_FW_IP_PROTOCOL_.NET_FW_IP_PROTOCOL_TCP;
            rule.LocalPorts = "3389";
            rule.Description = "RDP Attackers Blocked By Monitor.";
            rule.Direction = NET_FW_RULE_DIRECTION_.NET_FW_RULE_DIR_IN;
            rule.Action = NET_FW_ACTION_.NET_FW_ACTION_BLOCK;
            rule.Enabled = true;
            Policy2.Rules.Add(rule);
        }
        #endregion
    }
    class Attacker
    {
        public static readonly long LogDays = 10;
        public static readonly int Threshold = 5;
        public string IP { get; set; }

        public (long? id, DateTime? time) GetLast()
        {
            try
            {
                return Attacks.OrderBy(a => a.time).Last();
            }
            catch (Exception)
            {
                return (null, null);
            }
        }

        public List<(long? id, DateTime? time)> Attacks =
            new List<(long? id, DateTime? time)>();

        public void Clear() => Attacks.Clear();
        /// <summary>
        /// 添加失败记录,清理过期记录,返回是否触线
        /// </summary>
        /// <param name="dateTime"></param>
        /// <returns></returns>
        public bool Add(long? id, DateTime? dateTime)
        {
            Attacks.Add((id, dateTime));
            Attacks =
                Attacks.Where(a =>
                a.time > DateTime.Now.AddDays(-LogDays))
                .ToList();
            return Attacks.Count() > Threshold;
        }
        public Attacker(string ip)
        {
            IP = ip +
                (!ip.Contains("/") ? "/255.255.255.255" : "");
        }
    }
}