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