Rust小项目: 写一个简单的恶意流量阻断器

文章目录

当服务暴露在公网的时候,要时刻注意安全,因为会有各种爆破工具在网上不间断的无差别攻击,所以需要做一定的防护措施,比如使用fail2ban这样的服务来屏蔽一些恶意流量,而fail2ban的逻辑并不复杂,所以用Rust写一个玩玩,就当练练手了。

如果大家有SSH服务被不断爆破密码的苦恼可以试试fail2ban, 当然了,也可以直接禁用密码验证并使用秘钥验证。

下面是我其中一个服务器使用fail2ban的统计和屏蔽结果。

fail2ban-client status sshd
Status for the jail: sshd
|- Filter
|  |- Currently failed: 1
|  |- Total failed:     24358
|  `- Journal matches:  _SYSTEMD_UNIT=sshd.service + _COMM=sshd
`- Actions
   |- Currently banned: 4
   |- Total banned:     5834
   `- Banned IP list:   219.134.150.13 103.126.140.246 183.47.14.74 185.4.180.163

fail2ban的主要逻辑并不复杂,大致分为两步

  1. 根据应用类型找到攻击源
  2. 根据配置规则屏蔽攻击源一段时间

关于fail2ban的是用教程可以参考: https://linuxiac.com/how-to-protect-ssh-with-fail2ban/

找到攻击源

无论是SSH还是http服务都是有日志记录的,比如SSH的错误记录示例如下

Jan 27 10:33:56 localhost sshd[31825]: Failed password for root from 202.15.3.5 port 33410 ssh2

可以看到来自IP 202.15.3.5的用户尝试用root用户登录,但是失败了,我们可以简单的认为密码错误(或者说重试次数超过一定范围)的登录都来自攻击者。

http服务可以通过错误码,比如404来识别,我们可以简单的认为那些404产生于恶意扫描的网站的攻击者,不过这里就不展开了,一般来说web服务器,比如nginx,都内置了或者提供速率限制之类的接口,一般没必要在外部在实现类似的逻辑了。

由于不同的操作系统或不同的sshd服务版本可能sshd的日志文件位置不一样,所以为了简单起见这里仅考虑其日志被配置成输出到/var/log/secure的情况, 并且使用一个构造的secure.log文件来测试代码。

这里假设攻击日志如下:

Jan 27 10:33:48 localhost sshd[31234]: Failed password for root from 138.68.64.129 port 33410 ssh2
Jan 27 10:33:48 localhost sshd[31234]: Failed password for root from 138.68.64.129 port 33410 ssh2
Jan 27 10:33:48 localhost sshd[31234]: Failed password for root from 138.68.64.129 port 33410 ssh2
Jan 27 10:33:51 localhost sshd[31234]: Failed password for root from 165.227.68.95 port 33410 ssh2
Jan 27 10:34:51 localhost sshd[31234]: Failed password for root from 165.227.68.95 port 33410 ssh2
Jan 27 10:35:51 localhost sshd[31234]: Failed password for root from 165.227.68.95 port 33410 ssh2
Jan 27 10:36:51 localhost sshd[31234]: Failed password for root from 165.227.68.95 port 33410 ssh2

首先我们简单的根据攻击次数量阻止攻击源。

阻止攻击源的具体逻辑放在后面

use std::collections::HashMap;
use std::io::{BufRead, BufReader};
use std::fs::File;
use std::time::Duration;
use std::thread::sleep;

// 通过观察得知对应关键词。。。
const MALICIOUS_KEYWORDS: &str = "Failed password for ";

fn main() {
    let filepath = "secure.log";
    let file = File::open(filepath).expect("打开文件失败:");
    let mut reader = BufReader::new(file);

    let mut line = String::new();
    let mut counter: HashMap<String, i32> = HashMap::new();
    loop {
        line.clear();
        match reader.read_line(&mut line) {
            Ok(0) => {
                // 0.01 秒
                // 更好的办法应该是等待操作系统的通知信号, 这里就简单的sleep了。
                sleep(Duration::from_millis(10))
            },
            Ok(_) => {
                if line.contains(MALICIOUS_KEYWORDS) {
                    if let Some(ip) = line.split_whitespace().nth(10) {
                        counter.entry(ip.to_string()).and_modify(|e|*e+=1).or_insert(0);
                        if counter.get(ip).is_some_and(|count| count >= &3) {
                            println!("应该马上屏蔽ip: {ip}");
                        }
                    }
                }
            },
            Err(err) => {
                panic!("读取文件内容时出错: {err}")
            }
        };
    };
}

如果只是根据攻击次数屏蔽,那么太暴力了,毕竟人是有可能犯错的,所以更友好的判定逻辑应该是根据频率来选择是否屏蔽对应的攻击源,比如每分钟超过3次密码输入错误就屏蔽1个小时。

use std::collections::HashMap;
use std::io::{BufRead, BufReader};
use std::fs::File;
use std::time::Duration;
use std::thread::sleep;

use chrono::NaiveTime;

const MALICIOUS_KEYWORDS: &str = "Failed password for ";
const MONITOR_INTERVAL: Duration = Duration::from_secs(60);
const MONITOR_THRESHOLD: i32 = 3;


#[derive(Debug)]
struct Count {
    start: NaiveTime,
    count: i32,
}

fn main() {
    let filepath = "secure.log";
    let file = File::open(filepath).expect("打开文件失败:");
    let mut reader = BufReader::new(file);

    let mut line = String::new();
    let mut counter: HashMap<String, Count> = HashMap::new();
    loop {
        line.clear();
        match reader.read_line(&mut line) {
            Ok(0) => {
                // 0.01 秒
                // 更好的办法应该是等待操作系统的通知信号, 这里就简单的sleep了。
                sleep(Duration::from_millis(10));
                println!("{counter:?}");
            },
            Ok(_) => {
                if !line.contains(MALICIOUS_KEYWORDS) {
                    continue;
                }
                let splits: Vec<&str> = line.split_whitespace().collect();
                if splits.len() < 10 {
                    continue;
                };

                let now = chrono::Local::now().naive_local().time();
                let ip = splits[10].to_string();
                if counter.get(&ip).is_none() {
                    counter.insert(ip, Count{
                        start: now,
                        count: 1
                    });
                } else {
                    let target = counter.get_mut(&ip).unwrap();

                    // 时间超过指定的时间间隔就重新计数
                    if target.start + MONITOR_INTERVAL <= now {
                        target.start = now;
                        target.count = 1;
                    } else {
                        target.count += 1;
                    }

                    if target.count >= MONITOR_THRESHOLD {
                        println!("应该马上屏蔽ip: {ip}");
                    }
                };
            },
            Err(err) => {
                panic!("读取文件内容时出错: {err}")
            }
        };
    };
}

输出结果如下:

应该马上屏蔽ip: 138.68.64.129
应该马上屏蔽ip: 165.227.68.95
应该马上屏蔽ip: 165.227.68.95
{"165.227.68.95": Count { start: 15:22:22.448638100, count: 4 }, "138.68.64.129": Count { start: 15:22:22.448480800, count: 3 }}

如你所见,如果用来处理静态的数据可能会有一些问题,因为这里的监控时间以程序检测到的时间为准,所以日志里面的时间就没用了,对于这段代码来说,仿佛一瞬间来了7条数据,所以对应的两个IP都应该被屏蔽。

虽然还有一些问题没有解决,但是先让我研究一下怎么屏蔽攻击者吧。

屏蔽攻击源

屏蔽攻击源有很多办法,最简单的就是使用linux自带的防火墙了,我们可以使用iptables来操作。

假设我们要屏蔽IP 138.68.64.129, 我们只需要使用以下命令就可以了。

iptables -A INPUT -s 138.68.64.129 -j DROP

这条命令的意思是来自IP 138.68.64.129的数据包全部丢弃。

关于iptables更多的详细内容可以参考:https://phoenixnap.com/kb/iptables-tutorial-linux-firewall

那么,怎么删除对应的规则呢?这需要两步,首先获取到被插入的规则的序列号。

iptables -vnL --line-numbers
# 输出如下
Chain INPUT (policy ACCEPT 0 packets, 0 bytes)
num   pkts bytes target     prot opt in     out     source               destination         
1        0     0 DROP       all  --  *      *       138.68.64.129        0.0.0.0/0           

Chain FORWARD (policy ACCEPT 0 packets, 0 bytes)
num   pkts bytes target     prot opt in     out     source               destination         

Chain OUTPUT (policy ACCEPT 0 packets, 0 bytes)
num   pkts bytes target     prot opt in     out     source               destination 

可以看到刚插入的规则的序列号是1, 所以我们可以使用这个数字删除对应的规则。

iptables -D INPUT 1

最终代码

最终代码如下, 代码还是有些问题没有解决,比如超过一定的时间(比如说一个小时)应该解除屏蔽。

use std::collections::HashMap;
use std::io::{BufRead, BufReader};
use std::fs::File;
use std::time::Duration;
use std::thread::sleep;
use std::process;

use chrono::NaiveDateTime;

const MALICIOUS_KEYWORDS: &str = "Failed password for ";
const MONITOR_INTERVAL: Duration = Duration::from_secs(60);
const MONITOR_THRESHOLD: i32 = 3;


#[derive(Debug)]
struct Count {
    start: NaiveDateTime,
    count: i32,
}


fn drop_ip(ip: &str) {
    let args = format!("iptables -A INPUT -s {ip} -j DROP");
    // 使用sh的好处在于要不需要每个参数用arg方法传一次了,可以一次性全部放在"-c"参数后面
    match process::Command::new("sh")
        .arg("-c")
        .arg(&args)
        .output() {
        Ok(_) => return,
        Err(err) => println!("插入规则[{args}]时出错: {err}")
    }
}

fn main() {
    let filepath = "secure.log";
    let file = File::open(filepath).expect("打开文件失败:");
    let mut reader = BufReader::new(file);

    let mut line = String::new();
    let mut counter: HashMap<String, Count> = HashMap::new();
    let mut attackers: HashMap<String, ()> = HashMap::new();
    loop {
        line.clear();
        match reader.read_line(&mut line) {
            Ok(0) => {
                // 0.01 秒
                // 更好的办法应该是等待操作系统的通知信号, 这里就简单的sleep了。
                sleep(Duration::from_millis(10));
            },
            Ok(_) => {
                if !line.contains(MALICIOUS_KEYWORDS) {
                    continue;
                }
                let splits: Vec<&str> = line.split_whitespace().collect();
                if splits.len() < 10 {
                    continue;
                };

                let now = chrono::Local::now().naive_local();
                let ip = splits[10].to_string();
                if attackers.get(&ip).is_some() {
                    continue
                }

                if counter.get(&ip).is_none() {
                    counter.insert(ip, Count{
                        start: now,
                        count: 1
                    });
                } else {
                    let target = counter.get_mut(&ip).unwrap();

                    // 时间超过指定的时间间隔就重新计数
                    if target.start + MONITOR_INTERVAL <= now {
                        target.start = now;
                        target.count = 1;
                    } else {
                        target.count += 1;
                    }

                    if target.count >= MONITOR_THRESHOLD {
                        drop_ip(&ip);
                        attackers.insert(ip, ());
                    }
                };
            },
            Err(err) => {
                panic!("读取文件内容时出错: {err}")
            }
        };
    };
}

总结

因为并不是打算写一个fail2ban的替代品,所以写得比较简陋,没有完善解除屏蔽的逻辑,但是对于rustHashMap和调用系统命令应该有一定的练手应该有一定的帮助,所以就这样吧。

参考链接