半夜手机突然连续震动,登录服务器一看,/var/log/auth.log里全是Failed password记录,刷屏速度比我翻日志还快。这不是什么新鲜事,跑在公网上的 Linux 服务器只要开了 SSH,每天被扫描、被暴力破解几乎是例行公事。手动翻日志、复制 IP、再去查归属,一套下来半小时就没了,而且明天还得再来一遍。
这篇文章就是来解决这个问题的:用 Python 脚本自动读取 Linux 安全日志,把反复尝试登录的暴力破解源 IP 按次数统计出来,排出优先级,一次性告诉你要重点盯谁。整个脚本只依赖 Python 标准库,不用pip install任何东西,直接复制就能跑。适合刚接触 Linux 运维的开发者、兼职管服务器的程序员,以及所有不想在凌晨三点被日志刷到怀疑人生的人。
1. 拆解需求:暴力破解日志为什么值得单独写脚本
1.1 暴力破解的常见现象和日志痕迹
先还原一下真实场景。一台 Linux 服务器的 SSH 端口暴露在公网后,不需要有人特意针对你,各类扫描工具会自动全网扫描开放 22 端口的机器,然后拿常见用户名和弱密码字典轮番尝试。反映到日志里,就是这几类关键行:
Failed password for root from 203.0.113.10 port 50021 ssh2:密码试错了,最常见Invalid user admin from 198.51.100.23 port 51234:尝试一个根本不存在的用户名authentication failure:PAM 认证层报错,覆盖 sshd、sudo、su 等多个场景Bad protocol version identification:客户端协议版本异常,多数是扫描器特征
公网服务器一天产生几千条这类记录很平常。问题是这些记录是流水账,一行行混在正常登录、系统任务、cron 日志里。你想知道“哪个 IP 最活跃”“总共试了多少次”,光靠肉眼加 Ctrl+F 根本不现实。
我最早就是拿grep加管道组合命令来做,比如grep "Failed password" /var/log/auth.log | awk '{print $11}' | sort | uniq -c | sort -rn。这个方法应急可以,但有两个毛病:一是隐私字段位置在不同发行版上有差异,awk 列号经常对不上;二是想加时间过滤、阈值判断、归档日志支持,命令会越来越长,最终变成一串只有自己能看懂的天书。
1.2 为什么用 Python 而不是直接写 grep/awk 命令
用 Python 不是炫技,是这类统计任务做到后面必然的选择。grep擅长过滤,awk擅长按列处理,但它们本质上还是文本流工具,一旦涉及“多个正则条件组合判断”“跨多个归档文件处理”“按时间窗口过滤”“输出结构化报告”,命令行的可读性和可维护性就撑不住了。
Python 这边的好处很直接:正则表达式一次写好,逻辑分行写清楚,改阈值、改时间范围、加输出格式都动一个参数就行。而且这个脚本不需要装第三方库,re、argparse、collections.Counter、gzip全是标准库。也就是说,任何一台有 Python 3 的 Linux 机器都能直接跑,不需要联网装包,也不需要 virtualenv。
选型背后还有一个更实际的考量:脚本写完不用一次性,它会变成你每周固定要跑的工具。既然要长期用,就得选择一个能让你三个月后打开还能看懂、六个月后想扩展还能改得动的方案。命令行一条龙做不到,Python 可以。
2. 看懂安全日志:匹配规则的前提是把日志读明白
2.1 auth.log 和 secure 日志的 3 类关键行
先认准日志位置。Debian、Ubuntu 等系统安全日志在/var/log/auth.log,RHEL、CentOS 系列在/var/log/secure。不同发行版路径不一样,但日志行格式大体相同,脚本里把路径作为参数传进去就行。默认值我设成了/var/log/auth.log,你用 CentOS 就改成/var/log/secure。
拿到日志后要分清三类关键行,因为它们代表完全不同的事件:
| 日志内容 | 含义 | 你需要关心的程度 |
|---|---|---|
Failed password for root from ... | 密码错误,可能是手滑也可能是爆破 | 高,结合次数看 |
Invalid user guest from ... | 尝试不存在的用户 | 高,基本可以断定是扫描器 |
Accepted publickey for root from ... | 登录成功 | 极高,特别是出现在大量失败记录之后 |
一个容易被忽略的细节:很多人只盯着Failed password,却忘了在攻击成功或已经入侵之后,日志里会出现Accepted开头的行。如果某个 IP 先试了几百次然后突然成功登录,那问题就不是“封不封 IP”这么简单了,而是这台机器可能已经被控制。脚本虽然只统计失败记录,但你应该在拿到结果之后顺手搜一下这些可疑 IP 有没有对应的Accepted记录。这一点放到后面处置部分详细说。
2.2 日志里的时间、进程和 IP,正则分别怎么取
随便挑一行真实格式的日志拆开看:
Nov 5 03:12:07 web01 sshd[12345]: Failed password for root from 203.0.113.10 port 50021 ssh2从左到右分别是:月份、日期、时间、主机名、进程名加 PID、冒号、消息正文。学正则的时候总想着用一个表达式把整行一口吞下去,那样写出来的东西既难读又脆弱。更稳妥的做法是分两步:
第一步先切结构。用一行正则把“时间”和“冒号后的正文”分别提取出来:
r'^(\w{3}\s+\d{1,2}\s+\d{2}:\d{2}:\d{2})\s+\S+\s+\S+(?:\[\d+\])?:\s+(.*)$'第二步在正文里找关键词和 IP。先判断这行是不是失败认证记录,再从中抽取 IP:
r'(Failed password|Invalid user|authentication failure|Bad protocol version identification)' r'\b(\d{1,3}(?:\.\d{1,3}){3})\b'把两个正则拆开还有个好处:日志格式再怎么变,你只需要调整其中一个部分。比如换个发行版后时间戳带年份,就改第一段;关注的行为变了,就改第二段。逻辑解耦,调试的时候一眼能看出是结构没匹配上还是关键词没命中。
关于时间戳,新手最容易忽略跨年问题。日志里的月份缩写没有年份,如果用当前年份去解析 12 月 31 日的日志,在 1 月看时会算成未来时间。脚本里加了一个简单纠正:如果解析出的时间比当前时间还晚一天以上,就把年份减一。这个处理覆盖了绝大多数跨年场景,够用。
3. 完整代码与核心逻辑:从读文件到输出排行
3.1 完整 Python 脚本
直接上代码,文件命名为auth_log_analyzer.py,Python 3.6 以上版本都能运行:
#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ auth_log_analyzer.py 分析 Linux 安全日志(/var/log/auth.log、/var/log/secure 等), 统计产生失败认证记录的源 IP,并按次数从高到低输出。 用法示例: python3 auth_log_analyzer.py python3 auth_log_analyzer.py /var/log/auth.log.1 /var/log/auth.log.2.gz python3 auth_log_analyzer.py --threshold 20 --days 7 """ import argparse import gzip import re import sys from collections import Counter from datetime import datetime, timedelta # 日志行结构:时间 主机名 进程名[PID]: 消息内容 # 同时兼容没有 PID 的 logger 输出 LINE_RE = re.compile( r'^(\w{3}\s+\d{1,2}\s+\d{2}:\d{2}:\d{2})\s+\S+\s+\S+(?:\[\d+\])?:\s+(.*)$' ) # 与暴力破解 / 可疑扫描相关的内容 BAD_MSG_RE = re.compile( r'(Failed password|Invalid user|authentication failure|' r'Bad protocol version identification)' ) IP_RE = re.compile(r'\b(\d{1,3}(?:\.\d{1,3}){3})\b') def read_lines(path): """按行读取日志,支持 .gz 压缩文件。""" if path.endswith('.gz'): with gzip.open(path, 'rt', errors='ignore') as f: yield from f else: with open(path, 'r', errors='ignore') as f: yield from f def extract(line): """从一行日志中提取 (时间字符串, 源IP),不匹配则返回 None。""" m = LINE_RE.match(line) if not m: return None ts_str = m.group(1) body = m.group(2) if not BAD_MSG_RE.search(body): return None m_ip = IP_RE.search(body) if not m_ip: return None return ts_str, m_ip.group(1) def parse_ts(ts_str, now): """把 'Nov 5 10:05:23' 转成 datetime,并做简单的跨年纠正。""" try: ts = datetime.strptime(f'{now.year} {ts_str}', '%Y %b %d %H:%M:%S') except ValueError: return None if ts > now + timedelta(days=1): ts = ts.replace(year=ts.year - 1) return ts def main(): ap = argparse.ArgumentParser(description='统计 Linux 安全日志中的暴力破解源 IP') ap.add_argument('logs', nargs='*', default=['/var/log/auth.log'], help='日志文件或归档文件,默认读取 /var/log/auth.log') ap.add_argument('--threshold', type=int, default=5, help='只输出失败次数达到该值的 IP,默认 5') ap.add_argument('--days', type=int, default=0, help='只看最近 N 天的记录,0 表示不限制,默认 0') args = ap.parse_args() counter = Counter() last_ts = {} now = datetime.now() cutoff = now - timedelta(days=args.days) if args.days > 0 else None for path in args.logs: for line in read_lines(path): result = extract(line) if not result: continue ts_str, ip = result if cutoff is not None: ts = parse_ts(ts_str, now) if ts is None or ts < cutoff: continue counter[ip] += 1 last_ts[ip] = ts_str if not counter: print('没有在指定日志中发现匹配的失败认证记录。') return 0 print(f'{"源 IP":<20}{"失败次数":>8} 最后匹配时间') print('-' * 48) for ip, cnt in counter.most_common(): if cnt >= args.threshold: print(f'{ip:<20}{cnt:>8} {last_ts.get(ip, "-")}') over = sum(1 for v in counter.values() if v >= args.threshold) print(f'\n共发现 {len(counter)} 个不同来源 IP,其中达到阈值({args.threshold})的有 {over} 个。') return 0 if __name__ == '__main__': sys.exit(main())代码不复杂,核心就三个正则和一个Counter。但有几个细节是踩过坑之后才加进去的,下面逐个说。
3.2 脚本里最值得抄的 4 个细节
第一个细节是gzip自动识别。日志轮转是 Linux 的常规机制,/var/log/auth.log可能会被 logrotate 切成auth.log.1,再往前是auth.log.2.gz。如果不处理压缩文件,你只能分析当前日志,之前的攻击记录全丢。判断文件后缀.gz后使用gzip.open的一个小分支就解决了,脚本调用时把多个文件一次性传进去即可。
第二个细节是Counter配合most_common()做统计排序。计数这种事情用defaultdict也行,但Counter直接把“统计次数”和“按次数排序”封装好了,拿到手就是排好序的列表,代码少写五六行。这是 Python 标准库里被低估的好工具,遇到任何“统计频率、取 Top N”的需求,优先想它。
第三个细节是--days时间窗口。你可能只想看昨天的攻击,不想把三个月前的旧账翻出来。这个参数按天过滤,基于日志行里解析出的时间戳与当前时间比较。时间解析时的跨年纠正也在这里起作用,否则 1 月看 12 月的日志会把数据错误过滤掉。
第四个细节是--threshold阈值。默认值是 5,也就是失败次数达到 5 次的 IP 才输出。这个数字不要盲目用,内网测试机器可以设小一点,公网服务器建议调到 20 或 50,不然输出列表会很长。阈值其实就是报警灵敏度,调太低了天天误报,调太高了真出事时发现得晚。
3.3 运行一次,输出怎么读
在服务器上直接执行:
python3 auth_log_analyzer.py /var/log/auth.log输出大概是这样的:
源 IP 失败次数 最后匹配时间 ------------------------------------------------ 203.0.113.10 128 Nov 5 03:12:07 198.51.100.23 42 Nov 5 09:45:33 192.0.2.7 23 Nov 4 22:18:10 共发现 37 个不同来源 IP,其中达到阈值(5)的有 3 个。看结果时先看排名靠前的 IP,再看最后匹配时间。最后匹配时间离现在越近,说明攻击当前还在发生,优先级更高。如果输出为空,只有两种可能:日志路径不对,或者这台机器近期确实没有暴力破解尝试。前者你心里有数,后者恭喜你,你的网络环境比大多数人干净。
排名数字本身也要结合场景解读。一个 IP 尝试了 128 次,基本可以断定是字典爆破;一个 IP 只试了 3 次就被阈值过滤掉,可能是因为它刚扫到你的 IP 还没来得及展开攻击。所以脚本输出的是一个“优先级参考”,不是“攻击者完整名单”。
4. 验证与落地:没有攻击日志也能让脚本先跑通
4.1 用 logger 命令模拟暴力破解记录
刚拿到脚本,手头服务器可能干干净净一条失败记录都没有。这时候先别怀疑脚本,用系统自带的logger命令自己造几条记录出来验证。
logger是 Linux 标准的系统日志写入命令,可以指定日志类型和内容。在 root 权限下执行:
for ip in 203.0.113.10 203.0.113.10 198.51.100.23; do logger -p auth.notice -t sshd "Failed password for root from ${ip} port 50021 ssh2" done这段命令会往 auth 日志里写入三条模拟的失败认证记录,其中203.0.113.10出现了两次。然后跑一下脚本:
python3 auth_log_analyzer.py /var/log/auth.log如果你在输出里看到203.0.113.10计数为 2、198.51.100.23计数为 1,说明脚本解析、统计、排序的整条链路都是通的。这个测试方法会真实写入日志文件,介意的话做完了可以把对应行删掉,或者干脆在测试机上操作。
这种用系统工具模拟输入来验证脚本的套路,值得养成习惯。不管写什么解析脚本,先造一批可控的样本数据跑通逻辑,再上真实环境,能省掉大量排查时间。
4.2 把归档日志和 journalctl 数据也喂给脚本
生产环境里日志是分片的。/var/log/auth.log只是当前文件,历史记录在auth.log.1、auth.log.2.gz等归档里。脚本支持同时传入多个文件:
python3 auth_log_analyzer.py /var/log/auth.log /var/log/auth.log.1 /var/log/auth.log.2.gz如果你用 systemd 系统,也可以从 journal 里取数据喂给脚本:
journalctl -u ssh --since "7 days ago" --no-pager > /tmp/ssh_log.txt python3 auth_log_analyzer.py /tmp/ssh_log.txt这里要提醒一个兼容性问题。journalctl 默认输出的时间格式和传统 syslog 可能不同,如果时间格式带年份或带时区偏移,脚本里的LINE_RE第一段就匹配不上。遇到这种情况,先导出一小段看格式,再调整LINE_RE的时间部分。这也是做日志解析最常说的一句话:先看数据长什么样,再写解析规则。
4.3 接入 crontab 和告警,让脚本变成常用工具
一次跑一个文件只能看到当天情况,真正有价值的是定期汇总。服务器上配一条 cron 任务,让脚本每天早上自动跑一次,把结果追加到报告文件:
0 8 * * * /usr/bin/python3 /opt/scripts/auth_log_analyzer.py --days 1 --threshold 10 >> /var/log/ssh_daily_report.txt 2>&1如果想更主动一点,把输出通过邮件发给自己。Linux 上配好mail命令后:
0 8 * * * /usr/bin/python3 /opt/scripts/auth_log_analyzer.py --days 1 --threshold 20 | mail -s "昨日SSH暴力破解TOP" you@example.com这里要区分一个概念:脚本是审计和报告工具,不是自动防御工具。自动封禁 IP 的工作建议交给 fail2ban 这类专门软件,它会自己监控日志、触发封禁、处理解封。脚本的价值在于给你一张“攻击热度排行榜”,让你知道哪些 IP 值得关注、攻击趋势是在涨还是在跌。两件事分开做,各司其职,出了问题也好排查。
5. 踩坑记录与 IP 找到之后的处置建议
5.1 新手最容易踩的 5 个坑
| 坑 | 现象 | 解决办法 |
|---|---|---|
| 日志路径不对 | 脚本输出“没有匹配记录” | 先确认系统是 auth.log 还是 secure,用 ls 查看实际路径 |
| 权限不足 | PermissionError | 用 sudo 运行,或把脚本放到有日志读取权限的用户下 |
| 正则匹配不到 | 同一行肉眼能看到 IP,脚本就是抓不到 | 导出样本日志,逐行检查时间格式和进程标记差异 |
| 时间跨年误判 | 解析时间全在未来,--days 过滤后结果为空 | 用脚本内置的跨年纠正逻辑,注意只覆盖单年跨年 |
| IPv6 漏统计 | 来自 IPv6 地址的攻击不出现在结果里 | 脚本目前只匹配 IPv4,可自行添加 IPv6 正则 |
权限问题值得多说一句。/var/log/auth.log一般只有 root 和 adm 组能读,直接用普通用户跑大概率报错。实际使用中我建议把脚本放在/opt/scripts/下,用 sudo 手动跑,或者给脚本单独配一个可读取日志的系统用户,不要在 root 下跑定时任务跑上瘾,万一脚本被人改了就麻烦了。
正则匹配不到这个坑,最常见的原因是不同系统默认输出格式有差异。我见过有的系统日志里进程名后面没有 PID,有的时间戳格式多加了一个年份字段。排查方法很简单:head -n 20 /var/log/auth.log,拿真实日志对照LINE_RE的每一段,一段一段试。
5.2 找到 IP 之后,到底该不该手动封禁
脚本把可疑 IP 列出来了,下一步动作要冷静。很多人的第一反应是立刻iptables封掉,但实操中要区分情况。
如果是单纯尝试密码但从未成功的 IP,封禁是合理的,可以用 fail2ban 自动处理。如果某个 IP 既出现在失败列表里,又在之后的日志中留下了Accepted publickey for root的记录,那就不是封 IP 能解决的了,说明对方很可能已经成功进入了系统。此时要做的是立即隔离机器、排查登录凭据、检查是否被植入后门,而不是急着把 IP 加入黑名单。
另一个容易误伤的情况是代理和跳板。有些攻击 IP 其实是别人用代理池打的,封了一个还有下一个,意义有限。还有些时候,日志里的来源 IP 是公司出口 NAT 地址,内网很多人共用一个 IP,误封会导致正常同事也连不上。所以手工封禁前至少确认一下这个 IP 的行为特征,而不是看到次数多就一键封。
5.3 后续扩展方向
脚本目前输出的是终端文本,足够日常使用。想更进一步,可以把它扩展成三个方向:
一是输出 CSV,方便直接拖进表格工具做趋势分析。改动量很小,把结果循环里加一个csv.writer写文件即可。
二是做增量监控。每次只分析新产生的日志,记录上次读取到的文件偏移量,让脚本持续运行而不是每天全量扫描一遍。对日志量很大的服务器,这个优化能显著节省时间。
三是把结果写入数据库或直接调用告警接口。统计完成后,超过阈值的 IP 自动触发告警,相当于给服务器配了一个简易的入侵检测哨兵。
扩展的方向不一定要立刻全部实现,先让基础脚本稳定跑起来,确定你的服务器确实有暴力破解问题、确实需要更复杂的告警机制,再逐步加功能也不迟。
我在实际使用中还有一个个人习惯:脚本每周跑一次全量报告,单独存档。一个月下来回看趋势,能发现攻击高峰期、常用攻击源的变化,甚至能察觉到某些扫描行为是有规律的。这些信息单看某一天的日志得不到,但只要坚持定期统计,规律自己会浮出来。暴力破解不会消失,但被看见之后,就不再是深夜那条让你不安的通知了。