Appearance
SSH 被暴力破解怎么办?用 Python 按 IP 归属地自动封禁境外攻击 IP
凌晨两点,auth.log 里刷了一整屏 Failed password,来源 IP 从俄罗斯、巴西到印度尼西亚,一分钟换了二十几个。手动查归属地?一个个复制到网页里查,查到天亮也封不完。这是每台公网服务器都会遇到的日常:SSH 端口只要暴露在公网,扫描和暴力破解就断不了。
对付这种攻击,fail2ban 能按「失败次数」自动封 IP,但它不看来源。真正的攻击大多来自海外肉鸡,你的业务如果只服务国内用户,海外 IP 连试的资格都不该给。思路就一句话:失败来源先查归属地,非白名单国家直接封禁。这篇文章用 Python 实现这套逻辑,解析日志、查归属地、自动封禁,一次跑通。
一、为什么暴力破解封不完
先看清对手的样子:
- 分布式肉鸡:攻击者手里是成百上千台被控机器,每个 IP 只试几次,光靠失败次数阈值,根本够不到封禁线;
- 扫段攻击:凌晨的整段扫描,来源遍布全球,人工根本盯不过来;
- 撞库复用:弱密码账号一旦被爆破成功,同一来源会反复登录。
fail2ban 解决「同一 IP 疯狂试密码」,解决不了「每个 IP 只试两三次的分布式攻击」。而归属地白名单是从源头砍:业务只面向国内,那海外 IP 全部不值得信任。老运维的共识——用国家维度做第一道闸,比数失败次数高效得多。
二、方案设计
- 解析日志:读
/var/log/auth.log,用正则把Failed password ... from <ip>里的 IP 全捞出来; - 统计次数:collections.Counter 按 IP 聚合,失败次数多的排前面;
- 查归属地:逐 IP 调
https://ip9.com.cn/get?ip=xxx拿国家,带缓存和限速(免费版 60 次/分钟/IP); - 分类处置:国家明确且不在白名单 → 列入封禁名单;查询失败/未知 → 不封,留给 fail2ban 兜底;
- 执行封禁:
iptables -A INPUT -s <ip> -j DROP,默认 dry-run,确认名单后再加--ban真正执行。
封禁的粒度可以再细:只封「失败次数 ≥ 3 且非白名单」的海外 IP,避免误伤偶尔手滑输错密码的国内用户。
三、Python 实现
python
#!/usr/bin/env python3
# ssh_ip_guard.py —— SSH 暴力破解来源检查 + 自动封禁
# 用法:sudo python3 ssh_ip_guard.py # 先 dry-run 看名单
# sudo python3 ssh_ip_guard.py --ban # 确认后真正封禁
import re
import sys
import time
import json
import collections
import subprocess
import requests
WHITELIST = {"中国"} # 允许访问的国家/地区,按业务调整
LOG_PATH = "/var/log/auth.log"
FAILED_RE = re.compile(r"Failed password for .*? from (\d{1,3}(?:\.\d{1,3}){3})")
CACHE = {} # 归属地缓存,重复 IP 不重复查
LAST_CALL = 0.0
def query_country(ip):
"""查 IP 归属地,带缓存 + 限速(免费版 60 次/分钟/IP)"""
global LAST_CALL
if ip in CACHE:
return CACHE[ip]
now = time.time()
gap = 1.1 - (now - LAST_CALL)
if gap > 0:
time.sleep(gap)
try:
r = requests.get(f"https://ip9.com.cn/get?ip={ip}", timeout=5)
d = r.json()
country = (d.get("data") or {}).get("country", "")
except Exception:
country = "" # 接口异常按"未知"处理,宁可漏封不可错封
LAST_CALL = time.time()
CACHE[ip] = country
return country
def collect_failed():
"""从 auth.log 提取失败来源 IP 及次数"""
counts = collections.Counter()
with open(LOG_PATH, encoding="utf-8", errors="ignore") as f:
for line in f:
m = FAILED_RE.search(line)
if m:
counts[m.group(1)] += 1
return counts
def main():
counts = collect_failed()
print(f"auth.log 中失败来源 IP 共 {len(counts)} 个")
if not counts:
return
ban_list = []
for ip, times in counts.most_common():
if times < 3: # 失败少于 3 次的不碰,防误伤
continue
country = query_country(ip)
if country and country not in WHITELIST:
ban_list.append((ip, country, times))
print(f"其中非白名单国家、失败≥3 次:{len(ban_list)} 个")
for ip, country, times in ban_list:
print(f" {ip:18s} {country:10s} 失败 {times} 次")
if "--ban" not in sys.argv[1:]:
print("\n这是 dry-run。确认名单没问题后执行:sudo python3 ssh_ip_guard.py --ban")
return
for ip, _, _ in ban_list:
subprocess.run(["iptables", "-A", "INPUT", "-s", ip, "-j", "DROP"],
check=False)
print(f"已封禁 {len(ban_list)} 个 IP(iptables INPUT DROP)")
if __name__ == "__main__":
main()几个关键点:
- 限速放在查询层:免费接口 60 次/分钟,单次 sleep 保证不会打爆;缓存让同一个 IP 只查一次,几千条日志实际查询数少一个数量级;
- 容错方向是「漏」不是「错」:接口挂了返回空字符串,这类 IP 不封,交给 fail2ban 兜底——封错一个企业出口,比漏封一百个肉鸡更麻烦;
- 先 dry-run 再执行:脚本默认只打印名单,加
--ban才真正动 iptables,防止第一次跑就把自己封外面。
四、落地注意事项
- 防火墙白名单优先级:
iptables -A是追加规则,如果公司 VPN 出口、云厂商运维 IP 也在攻击名单里,先在前面加-I INPUT放行规则,或者把白名单从「国家」细化到「国家 + 网段」; - 配合 fail2ban 分两层:fail2ban 管「同 IP 高频失败」,本脚本管「海外低频试探」,两层互不干扰;
- 定期跑 + 记录:建议写进 crontab 每天凌晨跑一次 dry-run 并把名单写日志,封禁动作留痕,后续排查有据可查;
- iptables 重启会丢:记得
iptables-save > /etc/iptables/rules.v4持久化,或者改用 firewalld 的 permanent 规则; - IPv6 别漏:auth.log 里同样有 IPv6 来源,本脚本只覆盖 IPv4;IPv6 可以另写个正则,查归属地用同一个接口(支持 IPv6)。
总结
暴力破解封不干净,是因为你看不见对手长什么样。给失败来源补上「国家/地区」这一维,白名单外的直接 DROP,几分钟跑完,比手动封三天有效得多。整套逻辑的核心是 https://ip9.com.cn/get?ip=<攻击IP> 这个免费接口(无需注册,60 次/分钟/IP,返回国家、省份、运营商等字段),加上自己代码里的缓存与限速,就够支撑一台服务器日常的日志分析。想再收紧一点的,VIP 版的 ip_type(ISP/IDC/企业)还能把「肉鸡大多在 IDC 机房」这个特征用上,进一步降低误封概率。先 dry-run 看两天名单,再决定要不要开 --ban。