环境与需求
因业务需要,使用docker容器部署了ssh跳板机,后续发现容器部署的 ssh跳板机 的IP和端口泄漏,收到许多ssh访问登录攻击。大量未知用户的访问探测,触发 MaxStartups throtting 导致正常访问受到影响。因为是异常IP的异常访问,不想更改MaxStartups 配置,因此需要做一下限制。
主机上使用firewalld防火墙。
这里假设容器内的 SSH服务使用端口22,然后容器的 22端口 映射到了主机2222端口。
分析
异常日志
# sshd 日志 /var/log/auth.log 中的出现的异常访问记录
2025-07-25T11:14:14.410785+08:00 jumps-new sshd[556171]: Invalid user tmp from xx.53.78.68 port 8946
2025-07-25T11:14:14.443162+08:00 jumps-new sshd[556171]: Connection closed by invalid user tmp xx.53.78.68 port 8946 [preauth]
Docker 容器的 /var/log/auth.log
被映射到了主机的/var/log/docker-jumps-auth.log
。
防火墙规则放哪里
主机上使用firewalld防火墙,容器使用itpables规则。一般来说 未经特殊配置的 firewalld 防火墙管不住docker 的网络流量,所以我们准备使用 fail2ban 进行检测并在 iptables 规则链上做文章。
Docker 不建议我们操作 Docker的几条链,我们的自定义规则应该放在 DOCKER-USER
链上。
fail2ban 配置结构介绍
默认配置目录结构:
/etc/fail2ban/
├── action.d
├── fail2ban.conf
├── fail2ban.d
├── filter.d
├── jail.conf
├── jail.d
├── paths-arch.conf
├── paths-common.conf
├── paths-debian.conf
└── paths-opensuse.conf
5 directories, 6 files
fail2ban
的配置文件通常位于 /etc/fail2ban/jail.conf
,不过我们推荐创建一个 jail.local
文件来保留自定义配置(避免在软件更新时覆盖原文件),或者在/etc/fail2ban/jail.d
目录中创建自定义配置文件如customisation.local
。
关系大概是 jail.conf-->jial.local(覆盖conf)-->Filters-->Actions-->FirewallRulesUpdate
- 过滤器 (Filters – 存放在
/etc/fail2ban/filter.d/
目录): 每个Filter文件里都定义了一堆正则表达式(failregex
),用来从特定的日志文件中匹配那些“异常”的行为,比如登录失败、访问不存在的页面、或者尝试进行注入攻击的。它还能识别出“攻击人”的IP地址(通过
标签包裹),或者不去匹配这些异常。标签提取),你也可以主动忽略某些异常(使用
- 动作 (Actions – 存放在
/etc/fail2ban/action.d/
目录): 检查出异常访问后的处置方案。比如,最常见的动作就是用iptables
,firewalld
或nftables
(Linux防火墙工具)把“攻击人IP”给封禁一段时间,还可以配置成发送邮件通知管理员。 - 监狱 (Jails – 在
jail.conf
或jail.local
及jail.d/
目录下定义): 这就是“总体执行方案”!一个Jail把一个或多个Filter(负责异常行为识别)、一个或多个Action(采取什么应对措施)、以及其他辅助判断和操作的参数(比如监控哪个日志文件logpath
,在多长时间内findtime
尝试多少次算违规maxretry
、封禁多久bantime
、使用什么手段进行封禁banaction
等)给结合起来,针对特定的服务如SSH进行防护。
这里我们需要做的步骤:
- 自定义 Filters,确保 fail2ban 能识别当前场景下的异常日志
- 自定义 Actions 配置,启用防火墙规则进行拦截
实际操作
Filters 更新与调试
第一步调整/etc/fail2ban/filter.d/sshd.conf
配置。
Openssh 的默认 Fail2Ban Filter配置
“`
# Fail2Ban filter for openssh
#
# If you want to protect OpenSSH from being bruteforced by password
# authentication then get public key authentication working before disabling
# PasswordAuthentication in sshd_config.
#
#
# "Connection from <HOST> port \d+" requires LogLevel VERBOSE in sshd_config
#
[INCLUDES]
# Read common prefixes. If any customizations available — read them from
# common.local
before = common.conf
[DEFAULT]
_daemon = sshd
# optional prefix (logged from several ssh versions) like "error: ", "error: PAM: " or "fatal: "
__pref = (?:(?:error|fatal): (?:PAM: )?)?
# optional suffix (logged from several ssh versions) like " [preauth]"
#__suff = (?: port \d+)?(?: \[preauth\])?\s*
__suff = (?: (?:port \d+|on \S+|\[preauth\])){0,3}\s*
__on_port_opt = (?: (?:port \d+|on \S+)){0,2}
# close by authenticating user:
__authng_user = (?: (?:invalid|authenticating) user <F-USER>\S+|.*?</F-USER>)?
# for all possible (also future) forms of "no matching (cipher|mac|MAC|compression method|key exchange method|host key type) found",
# see ssherr.c for all possible SSH_ERR_…_ALG_MATCH errors.
__alg_match = (?:(?:\w+ (?!found\b)){0,2}\w+)
# PAM authentication mechanism, can be overridden, e. g. `filter = sshd[__pam_auth='pam_ldap']`:
__pam_auth = pam_[a-z]+
[Definition]
prefregex = ^<F-MLFID>%(__prefix_line)s</F-MLFID>%(__pref)s<F-CONTENT>.+</F-CONTENT>$
cmnfailre = ^[aA]uthentication (?:failure|error|failed) for <F-USER>.*</F-USER> from <HOST>( via \S+)?%(__suff)s$
^User not known to the underlying authentication module for <F-USER>.*</F-USER> from <HOST>%(__suff)s$
<cmnfailre-failed-pub-<publickey>>
^Failed <cmnfailed> for (?P<cond_inv>invalid user )?<F-USER>(?P<cond_user>\S+)|(?(cond_inv)(?:(?! from ).)*?|[^:]+)</F-USER> from <HOST>%(__on_port_opt)s(?: ssh\d*)?(?(cond_user): |(?:(?:(?! from ).)*)$)
^<F-USER>ROOT</F-USER> LOGIN REFUSED FROM <HOST>
^[iI](?:llegal|nvalid) user <F-USER>.*?</F-USER> from <HOST>%(__suff)s$
^User <F-USER>\S+|.*?</F-USER> from <HOST> not allowed because not listed in AllowUsers%(__suff)s$
^User <F-USER>\S+|.*?</F-USER> from <HOST> not allowed because listed in DenyUsers%(__suff)s$
^User <F-USER>\S+|.*?</F-USER> from <HOST> not allowed because not in any group%(__suff)s$
^refused connect from \S+ \(<HOST>\)
^Received <F-MLFFORGET>disconnect</F-MLFFORGET> from <HOST>%(__on_port_opt)s:\s*3: .*: Auth fail%(__suff)s$
^User <F-USER>\S+|.*?</F-USER> from <HOST> not allowed because a group is listed in DenyGroups%(__suff)s$
^User <F-USER>\S+|.*?</F-USER> from <HOST> not allowed because none of user's groups are listed in AllowGroups%(__suff)s$
^<F-NOFAIL>%(__pam_auth)s\(sshd:auth\):\s+authentication failure;</F-NOFAIL>(?:\s+(?:(?:logname|e?uid|tty)=\S*)){0,4}\s+ruser=<F-ALT_USER>\S*</F-ALT_USER>\s+rhost=<HOST>(?:\s+user=<F-USER>\S*</F-USER>)?%(__suff)s$
^maximum authentication attempts exceeded for <F-USER>.*</F-USER> from <HOST>%(__on_port_opt)s(?: ssh\d*)?%(__suff)s$
^User <F-USER>\S+|.*?</F-USER> not allowed because account is locked%(__suff)s
^<F-MLFFORGET>Disconnecting</F-MLFFORGET>(?: from)?(?: (?:invalid|authenticating)) user <F-USER>\S+</F-USER> <HOST>%(__on_port_opt)s:\s*Change of username or service not allowed:\s*.*\[preauth\]\s*$
^Disconnecting: Too many authentication failures(?: for <F-USER>\S+|.*?</F-USER>)?%(__suff)s$
^<F-NOFAIL>Received <F-MLFFORGET>disconnect</F-MLFFORGET></F-NOFAIL> from <HOST>%(__on_port_opt)s:\s*11:
<mdre-<mode>-other>
^<F-MLFFORGET><F-MLFGAINED>Accepted \w+</F-MLFGAINED></F-MLFFORGET> for <F-USER>\S+</F-USER> from <HOST>(?:\s|$)
cmnfailed-any = \S+
cmnfailed-ignore = \b(?!publickey)\S+
cmnfailed-invalid = <cmnfailed-ignore>
cmnfailed-nofail = (?:<F-NOFAIL>publickey</F-NOFAIL>|\S+)
cmnfailed = <cmnfailed-<publickey>>
mdre-normal =
# used to differentiate "connection closed" with and without `[preauth]` (fail/nofail cases in ddos mode)
mdre-normal-other = ^<F-NOFAIL><F-MLFFORGET>(Connection (?:closed|reset)|Disconnected)</F-MLFFORGET></F-NOFAIL> (?:by|from)%(__authng_user)s <HOST>(?:%(__suff)s|\s*)$
mdre-ddos = ^Did not receive identification string from <HOST>
^kex_exchange_identification: (?:read: )?(?:[Cc]lient sent invalid protocol identifier|[Cc]onnection (?:closed by remote host|reset by peer))
^Bad protocol version identification '.*' from <HOST>
^<F-NOFAIL>SSH: Server;Ltype:</F-NOFAIL> (?:Authname|Version|Kex);Remote: <HOST>-\d+;[A-Z]\w+:
^Read from socket failed: Connection <F-MLFFORGET>reset</F-MLFFORGET> by peer
^banner exchange: Connection from <HOST><__on_port_opt>: invalid format
# same as mdre-normal-other, but as failure (without <F-NOFAIL> with [preauth] and with <F-NOFAIL> on no preauth phase as helper to identify address):
mdre-ddos-other = ^<F-MLFFORGET>(Connection (?:closed|reset)|Disconnected)</F-MLFFORGET> (?:by|from)%(__authng_user)s <HOST>%(__on_port_opt)s\s+\[preauth\]\s*$
^<F-NOFAIL><F-MLFFORGET>(Connection (?:closed|reset)|Disconnected)</F-MLFFORGET></F-NOFAIL> (?:by|from)%(__authng_user)s <HOST>(?:%(__on_port_opt)s|\s*)$
mdre-extra = ^Received <F-MLFFORGET>disconnect</F-MLFFORGET> from <HOST>%(__on_port_opt)s:\s*14: No(?: supported)? authentication methods available
^Unable to negotiate with <HOST>%(__on_port_opt)s: no matching <__alg_match> found.
^Unable to negotiate a <__alg_match>
^no matching <__alg_match> found:
# part of mdre-ddos-other, but user name is supplied (invalid/authenticating) on [preauth] phase only:
mdre-extra-other = ^<F-MLFFORGET>Disconnected</F-MLFFORGET>(?: from)?(?: (?:invalid|authenticating)) user <F-USER>\S+|.*?</F-USER> <HOST>%(__on_port_opt)s \[preauth\]\s*$
mdre-aggressive = %(mdre-ddos)s
%(mdre-extra)s
# mdre-extra-other is fully included within mdre-ddos-other:
mdre-aggressive-other = %(mdre-ddos-other)s
# Parameter "publickey": nofail (default), invalid, any, ignore
publickey = nofail
# consider failed publickey for invalid users only:
cmnfailre-failed-pub-invalid = ^Failed publickey for invalid user <F-USER>(?P<cond_user>\S+)|(?:(?! from ).)*?</F-USER> from <HOST>%(__on_port_opt)s(?: ssh\d*)?(?(cond_user): |(?:(?:(?! from ).)*)$)
# consider failed publickey for valid users too (don't need RE, see cmnfailed):
cmnfailre-failed-pub-any =
# same as invalid, but consider failed publickey for valid users too, just as no failure (helper to get IP and user-name only, see cmnfailed):
cmnfailre-failed-pub-nofail = <cmnfailre-failed-pub-invalid>
# don't consider failed publickey as failures (don't need RE, see cmnfailed):
cmnfailre-failed-pub-ignore =
cfooterre = ^<F-NOFAIL>Connection from</F-NOFAIL> <HOST>
failregex = %(cmnfailre)s
<mdre-<mode>>
%(cfooterre)s
# Parameter "mode": normal (default), ddos, extra or aggressive (combines all)
# Usage example (for jail.local):
# [sshd]
# mode = extra
# # or another jail (rewrite filter parameters of jail):
# [sshd-aggressive]
# filter = sshd[mode=aggressive]
#
mode = normal
#filter = sshd[mode=aggressive]
ignoreregex =
maxlines = 1
journalmatch = _SYSTEMD_UNIT=sshd.service + _COMM=sshd
# DEV Notes:
#
# "Failed \S+ for .*? from <HOST>…" failregex uses non-greedy catch-all because
# it is coming before use of <HOST> which is not hard-anchored at the end as well,
# and later catch-all's could contain user-provided input, which need to be greedily
# matched away first.
#
# Author: Cyril Jaquier, Yaroslav Halchenko, Petr Voralek, Daniel Black and Sergey Brester aka sebres
# Rewritten using prefregex (and introduced "mode" parameter) by Serg G. Brester.
“`
cmnfailre
这个变量是我们需要关注的,日志前面的 时间 进程相关信息在使用这里的正则匹配时已经剔除了,所以可以用^
进行匹配,具体文档参见man jail.conf
。
异常日志是:
# sshd 日志 /var/log/auth.log 中的出现的异常访问记录
2025-07-25T11:14:14.410785+08:00 jumps-new sshd[556171]: Invalid user tmp from xx.53.78.68 port 8946
2025-07-25T11:14:14.443162+08:00 jumps-new sshd[556171]: Connection closed by invalid user tmp xx.53.78.68 port 8946 [preauth]
开始我们准备匹配后面的Connection closed 这一行,在修改了 cmnfailre
之后使用如下命令进行调试:
# 查看状态
fail2ban-client status
fail2ban-client status docker-sshd
# man fail2ban-regex or fail2ban-regex --help
fail2ban-regex /var/log/docker-jumps-auth.log /etc/fail2ban/filter.d/sshd.conf --print-all-ignored -v
fail2ban-regex /var/log/docker-jumps-auth.log /etc/fail2ban/filter.d/sshd.conf --print-all-missed -v
fail2ban-regex /var/log/docker-jumps-auth.log /etc/fail2ban/filter.d/sshd.conf --print-all-matched -v
fail2ban-client get docker-sshd failregex
fail2ban-client get docker-sshd ignoreregex
fail2ban-client get docker-sshd ignoreip
fail2ban-client get docker-sshd banaction
fail2ban-client status docker-sshd |grep 22.217.95.xxx
iptables -nvL
调试过程中发现目标日志都被Ignore了!进一步查看发现有个F-NOFAIL标签刚好影响到这一行,所以日志被忽略了,因此Action没有执行。那可以选择匹配 Invalid user这一行,遂在 /etc/fail2ban/filter.d/sshd.conf
中
#cmnfailre = 下面加一行
^[iI]nvalid user <F-USER>.*</F-USER> from <HOST>%(__on_port_opt)s(?: ssh\d*)?$
可以发现现在能正确匹配日志。后面就是启用 Fail2Ban 了。
配置 Fail2Ban 检查ssh日志
使用/etc/fail2ban/jail.local
!
创建默认配置文件的副本:
cp /etc/fail2ban/jail.conf /etc/fail2ban/jail.local
# jail.conf默认有许多常见服务的配置,你可以删除或者创建新文件, 或者在 jail.d目录下创建
默认的sshd配
[sshd]
# To use more aggressive sshd modes set filter parameter "mode" in jail.local:
# normal (default), ddos, extra or aggressive (combines all).
# See "tests/files/logs/sshd" or "filter.d/sshd.conf" for usage example and details.
#mode = normal
port = ssh
logpath = %(sshd_log)s
backend = %(sshd_backend)s
我们创建一个新的块:
[DEFAULT]
ignoreip = 127.0.0.1/8 ::1
bantime = 48h
findtime = 2m
maxretry = 2
#destemail = [email protected]
#sender = [email protected]
#action = %(action_mwl)s
[docker-sshd]
enabled = true
mode = aggressive
port = 2222
filter = sshd
chain = DOCKER-USER
banaction = iptables-multiport
logpath = /var/log/docker-jumps-auth.log
maxretry = 2
bantime = 30m
findtime = 2m
常用参数说明:
ignoreip = 127.0.0.1/8 ::1
:添加 IP 白名单,避免自己被封锁(生产环境很重要!)。mode = aggressive
模式enabled = true
:启用 SSH 防护。port = 2222
:指定 SSH 使用的端口(通常是 22,如果你使用自定义端口,可以修改这里)。logpath = /var/log/docker-jumps-auth.log
:指定 SSH 登录日志文件的路径。filter = sshd
:指定用于匹配 SSH 登录失败的日志过滤器,该过滤器在filter.d
目录中以sshd.conf
的形式存在,可以在这里修改自定义的 正则表达式规则,或者在这里创建新的xxxfilter.conf。maxretry = 2
:设定最大登录失败次数为 2 次。bantime = 48h
:设置 IP 地址被禁止的时间为 xxx 秒(或者 40m 2h ),-1
为永久封禁。findtime = 2m
:设置检查的时间窗口为 2 分钟,意味着在这个时间段内超过 2 次失败的登录会触发封禁。chain = DOCKER-USER
指定 防火墙规则使用的链名称
启动或者重启重载fail2ban 你会发现它没有能正确进行拦截!原因如下:
DOCKER-USER
链数据包进入 DOCKER-USER链时 **已经完成了 NAT**,所以使用2222端口是错误的,应该使用容器里ssh服务的22端口!
[DEFAULT]
ignoreip = 127.0.0.1/8 ::1
bantime = 48h
findtime = 2m
maxretry = 2
#destemail = [email protected]
#sender = [email protected]
#action = %(action_mwl)s
[docker-sshd]
enabled = true
mode = aggressive
port = 22 # <<<<< 注意这里
filter = sshd
chain = DOCKER-USER # <<<<< 注意这里
banaction = iptables-multiport
logpath = /var/log/docker-jumps-auth.log
maxretry = 2
bantime = 30m
findtime = 2m
这样所有容器的22端口都受到影响了,我们给它再限制一下范围。这个操作是通过banaction指定的。
Actions 自定义
Action配置保存在 /etc/fail2ban/action.d/
目录,进来之后复制一下原有iptables-multiport.conf
为iptables-docker-multiport.conf
,再进行编辑 vim iptables-docker-multiport.conf
。
观察发现这里用的iptables进行的操作,那只要指定一下 destination IP即可,在需要的地方都加一下 -d
。比如:
Click_to_view_details
“`ini
# Fail2Ban configuration file
#
# Author: Cyril Jaquier
# Modified by Yaroslav Halchenko for multiport banning
#
[INCLUDES]
before = iptables-common.conf
[Definition]
# Option: actionstart
# Notes.: command executed on demand at the first ban (or at the start of Fail2Ban if actionstart_on_demand is set to false).
# Values: CMD
#
actionstart = <iptables> -N f2b-<name>
<iptables> -A f2b-<name> -j <returntype>
<iptables> -I <chain> -p <protocol> -m multiport –dports <port> -j f2b-<name>
# Option: actionstop
# Notes.: command executed at the stop of jail (or at the end of Fail2Ban)
# Values: CMD
#
actionstop = <iptables> -D <chain> -p <protocol> -m multiport –dports <port> -j f2b-<name>
<actionflush>
<iptables> -X f2b-<name>
# Option: actioncheck
# Notes.: command executed once before each actionban command
# Values: CMD
#
actioncheck = <iptables> -n -L <chain> | grep -q 'f2b-<name>[ \t]'
# Option: actionban
# Notes.: command executed when banning an IP. Take care that the
# command is executed with Fail2Ban user rights.
# Tags: See jail.conf(5) man page
# Values: CMD
#
actionban = <iptables> -I f2b-<name> 1 -s <ip> -j <blocktype>
# Option: actionunban
# Notes.: command executed when unbanning an IP. Take care that the
# command is executed with Fail2Ban user rights.
# Tags: See jail.conf(5) man page
# Values: CMD
#
actionunban = <iptables> -D f2b-<name> -s <ip> -j <blocktype>
[Init]
root@proxy1:/etc/fail2ban# cat action.d/iptables-docker-multiport.conf
# Fail2Ban configuration file
#
# Author: Cyril Jaquier
# Modified by Yaroslav Halchenko for multiport banning
#
[INCLUDES]
before = iptables-common.conf
[Definition]
# Option: actionstart
# Notes.: command executed on demand at the first ban (or at the start of Fail2Ban if actionstart_on_demand is set to false).
# Values: CMD
#
actionstart = <iptables> -N f2b-<name>
<iptables> -A f2b-<name> -j <returntype>
<iptables> -I <chain> -p <protocol> -m multiport –dports <port> -d <destip> -j f2b-<name>
# Option: actionstop
# Notes.: command executed at the stop of jail (or at the end of Fail2Ban)
# Values: CMD
#
actionstop = <iptables> -D <chain> -p <protocol> -m multiport –dports <port> -d <destip> -j f2b-<name>
<actionflush>
<iptables> -X f2b-<name>
# Option: actioncheck
# Notes.: command executed once before each actionban command
# Values: CMD
#
actioncheck = <iptables> -n -L <chain> | grep -q 'f2b-<name>[ \t]'
# Option: actionban
# Notes.: command executed when banning an IP. Take care that the
# command is executed with Fail2Ban user rights.
# Tags: See jail.conf(5) man page
# Values: CMD
#
actionban = <iptables> -I f2b-<name> 1 -s <ip> -d <destip> -j <blocktype>
# Option: actionunban
# Notes.: command executed when unbanning an IP. Take care that the
# command is executed with Fail2Ban user rights.
# Tags: See jail.conf(5) man page
# Values: CMD
#
actionunban = <iptables> -D f2b-<name> -s <ip> -d <destip> -j <blocktype>
[Init]
“`
完事之后再在jail.local
中使用我们新创建并修改的 action,并传入参数 destip=容器IP
重启fail2ban 即可生效。
[DEFAULT]
ignoreip = 127.0.0.1/8 ::1 # 还应该有 公司的 可信IP
bantime = 48h
findtime = 2m
maxretry = 2
#destemail = [email protected]
#sender = [email protected]
#action = %(action_mwl)s
[docker-sshd]
enabled = true
mode = aggressive
port = 22 # <<<<< 注意这里
filter = sshd
chain = DOCKER-USER # <<<<< 注意这里
#banaction = iptables-multiport
banaction = iptables-docker-multiport[destip=10.100.0.199] # <<<<< 注意这里
logpath = /var/log/docker-jumps-auth.log
maxretry = 2
bantime = 30m
findtime = 2m
至此完成 使用fail2ban 封禁容器端口的异常访问。
