diff --git a/config/filter.d/sshd.conf b/config/filter.d/sshd.conf
index b5a997f5..aa01c85a 100644
--- a/config/filter.d/sshd.conf
+++ b/config/filter.d/sshd.conf
@@ -29,6 +29,9 @@ __on_port_opt = (?: (?:port \d+|on \S+)){0,2}
# 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 = ^%(__prefix_line)s%(__pref)s.+$
@@ -46,13 +49,13 @@ cmnfailre = ^[aA]uthentication (?:failure|error|failed) for .*
^Received disconnect from %(__on_port_opt)s:\s*3: .*: Auth fail%(__suff)s$
^User .+ from not allowed because a group is listed in DenyGroups%(__suff)s$
^User .+ from not allowed because none of user's groups are listed in AllowGroups%(__suff)s$
- ^pam_unix\(sshd:auth\):\s+authentication failure;\s*logname=\S*\s*uid=\d*\s*euid=\d*\s*tty=\S*\s*ruser=\S*\s*rhost=\s.*%(__suff)s$
+ ^%(__pam_auth)s\(sshd:auth\):\s+authentication failure;(?:\s+(?:(?:logname|e?uid|tty)=\S*)){0,4}\s+ruser=\S*\s+rhost=(?:\s+user=\S*)?%(__suff)s$
^(error: )?maximum authentication attempts exceeded for .* from %(__on_port_opt)s(?: ssh\d*)?%(__suff)s$
^User .+ not allowed because account is locked%(__suff)s
^Disconnecting: Too many authentication failures(?: for .+?)?%(__suff)s
^Received disconnect from %(__on_port_opt)s:\s*11:
^Connection closed by %(__suff)s$
- ^Accepted publickey for \S+ from (?:\s|$)
+ ^Accepted \w+ for \S+ from (?:\s|$)
mdre-normal =
diff --git a/fail2ban/server/filter.py b/fail2ban/server/filter.py
index 8326f049..01c2e059 100644
--- a/fail2ban/server/filter.py
+++ b/fail2ban/server/filter.py
@@ -588,31 +588,62 @@ class Filter(JailThread):
return ignoreRegexIndex
return None
+ def _updateUsers(self, fail, user=()):
+ users = fail.get('users')
+ # only for regex contains user:
+ if user:
+ if not users:
+ fail['users'] = users = set()
+ users.add(user)
+ return users
+ return None
+
def _mergeFailure(self, mlfid, fail, failRegex):
mlfidFail = self.mlfidCache.get(mlfid) if self.__mlfidCache else None
+ users = None
+ nfflgs = 0
+ if fail.get('nofail'): nfflgs |= 1
+ if fail.get('mlfforget'): nfflgs |= 2
# if multi-line failure id (connection id) known:
if mlfidFail:
mlfidGroups = mlfidFail[1]
- # update - if not forget (disconnect/reset):
- if not fail.get('mlfforget'):
- mlfidGroups.update(fail)
- else:
- self.mlfidCache.unset(mlfid) # remove cached entry
- # merge with previous info:
- fail2 = mlfidGroups.copy()
- fail2.update(fail)
- if not fail.get('nofail'): # be sure we've correct current state
- try:
- del fail2['nofail']
- except KeyError:
- pass
- fail2["matches"] = fail.get("matches", []) + failRegex.getMatchedTupleLines()
- fail = fail2
- elif not fail.get('mlfforget'):
+ # update users set (hold all users of connect):
+ users = self._updateUsers(mlfidGroups, fail.get('user'))
+ # be sure we've correct current state ('nofail' only from last failure)
+ try:
+ del mlfidGroups['nofail']
+ except KeyError:
+ pass
+ # update not empty values:
+ mlfidGroups.update(((k,v) for k,v in fail.iteritems() if v))
+ fail = mlfidGroups
+ # if forget (disconnect/reset) - remove cached entry:
+ if nfflgs & 2:
+ self.mlfidCache.unset(mlfid)
+ elif not (nfflgs & 2): # not mlfforget
+ users = self._updateUsers(fail, fail.get('user'))
mlfidFail = [self.__lastDate, fail]
self.mlfidCache.set(mlfid, mlfidFail)
- if fail.get('nofail'):
- fail["matches"] = failRegex.getMatchedTupleLines()
+ # check users in order to avoid reset failure by multiple logon-attempts:
+ if users and len(users) > 1:
+ # we've new user, reset 'nofail' because of multiple users attempts:
+ try:
+ del fail['nofail']
+ except KeyError:
+ pass
+ # merge matches:
+ if not fail.get('nofail'): # current state (corresponding users)
+ try:
+ m = fail.pop("nofail-matches")
+ m += fail.get("matches", [])
+ except KeyError:
+ m = fail.get("matches", [])
+ if not (nfflgs & 2): # not mlfforget:
+ m += failRegex.getMatchedTupleLines()
+ fail["matches"] = m
+ elif not (nfflgs & 2) and (nfflgs & 1): # not mlfforget and nofail:
+ fail["nofail-matches"] = fail.get("nofail-matches", []) + failRegex.getMatchedTupleLines()
+ # return merged:
return fail
@@ -738,6 +769,7 @@ class Filter(JailThread):
fail = self._mergeFailure(mlfid, fail, failRegex)
# bypass if no-failure case:
if fail.get('nofail'):
+ # if not users or len(users) <= 1:
logSys.log(7, "Nofail by mlfid %r in regex %s: %s",
mlfid, failRegexIndex, fail.get('mlfforget', "waiting for failure"))
if not self.checkAllRegex: return failList
diff --git a/fail2ban/tests/config/filter.d/zzz-sshd-obsolete-multiline.conf b/fail2ban/tests/config/filter.d/zzz-sshd-obsolete-multiline.conf
index 283e725c..5717c316 100644
--- a/fail2ban/tests/config/filter.d/zzz-sshd-obsolete-multiline.conf
+++ b/fail2ban/tests/config/filter.d/zzz-sshd-obsolete-multiline.conf
@@ -27,6 +27,9 @@ __prefix_line_ml2 = %(__suff)s$^(?P=__prefix)%(__pref)s
# 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]
cmnfailre = ^%(__prefix_line_sl)s[aA]uthentication (?:failure|error|failed) for .* from ( via \S+)?\s*%(__suff)s$
@@ -42,7 +45,7 @@ cmnfailre = ^%(__prefix_line_sl)s[aA]uthentication (?:failure|error|failed) for
^%(__prefix_line_sl)sReceived disconnect from %(__on_port_opt)s:\s*3: .*: Auth fail%(__suff)s$
^%(__prefix_line_sl)sUser .+ from not allowed because a group is listed in DenyGroups\s*%(__suff)s$
^%(__prefix_line_sl)sUser .+ from not allowed because none of user's groups are listed in AllowGroups\s*%(__suff)s$
- ^%(__prefix_line_sl)spam_unix\(sshd:auth\):\s+authentication failure;\s*logname=\S*\s*uid=\d*\s*euid=\d*\s*tty=\S*\s*ruser=\S*\s*rhost=\s.*%(__suff)s$
+ ^%(__prefix_line_ml1)s%(__pam_auth)s\(sshd:auth\):\s+authentication failure;\s*logname=\S*\s*uid=\d*\s*euid=\d*\s*tty=\S*\s*ruser=\S*\s*rhost=\s.*%(__suff)s$%(__prefix_line_ml2)sConnection closed
^%(__prefix_line_sl)s(error: )?maximum authentication attempts exceeded for .* from %(__on_port_opt)s(?: ssh\d*)? \[preauth\]$
^%(__prefix_line_ml1)sUser .+ not allowed because account is locked%(__prefix_line_ml2)sReceived disconnect from %(__on_port_opt)s:\s*11: .+%(__suff)s$
^%(__prefix_line_ml1)sDisconnecting: Too many authentication failures(?: for .+?)?%(__suff)s%(__prefix_line_ml2)sConnection closed by %(__suff)s$
diff --git a/fail2ban/tests/files/logs/sshd b/fail2ban/tests/files/logs/sshd
index 7d93642d..8f7ba264 100644
--- a/fail2ban/tests/files/logs/sshd
+++ b/fail2ban/tests/files/logs/sshd
@@ -134,7 +134,7 @@ Sep 29 17:15:02 spaceman sshd[12946]: Failed password for user from 127.0.0.1 po
# failJSON: { "time": "2004-09-29T17:15:02", "match": true , "host": "127.0.0.1", "desc": "Injecting while exhausting initially present {0,100} match length limits set for ruser etc" }
Sep 29 17:15:02 spaceman sshd[12946]: Failed password for user from 127.0.0.1 port 20000 ssh1: ruser XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX from 1.2.3.4
# failJSON: { "time": "2004-09-29T17:15:03", "match": true , "host": "aaaa:bbbb:cccc:1234::1:1", "desc": "Injecting while exhausting initially present {0,100} match length limits set for ruser etc" }
-Sep 29 17:15:03 spaceman sshd[12946]: Failed password for user from aaaa:bbbb:cccc:1234::1:1 port 20000 ssh1: ruser XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX from 1.2.3.4
+Sep 29 17:15:03 spaceman sshd[12947]: Failed password for user from aaaa:bbbb:cccc:1234::1:1 port 20000 ssh1: ruser XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX from 1.2.3.4
# failJSON: { "time": "2004-11-11T08:04:51", "match": true , "host": "127.0.0.1", "desc": "Injecting on username ssh 'from 10.10.1.1'@localhost" }
Nov 11 08:04:51 redbamboo sshd[2737]: Failed password for invalid user from 10.10.1.1 from 127.0.0.1 port 58946 ssh2
@@ -212,9 +212,46 @@ Apr 27 13:02:04 host sshd[29116]: input_userauth_request: invalid user root [pre
# failJSON: { "time": "2005-04-27T13:02:04", "match": true , "host": "1.2.3.4", "desc": "No Bye-Bye" }
Apr 27 13:02:04 host sshd[29116]: Received disconnect from 1.2.3.4: 11: Normal Shutdown, Thank you for playing [preauth]
-# Match sshd auth errors on OpenSUSE systems
-# failJSON: { "time": "2015-04-16T20:02:50", "match": true , "host": "222.186.21.217", "desc": "Authentication for user failed" }
-2015-04-16T18:02:50.321974+00:00 host sshd[2716]: pam_unix(sshd:auth): authentication failure; logname= uid=0 euid=0 tty=ssh ruser= rhost=222.186.21.217 user=root
+# Match sshd auth errors on OpenSUSE systems (gh-1024)
+# failJSON: { "match": false, "desc": "No failure until closed or another fail (e. g. F-MLFFORGET by success/accepted password can avoid failure, see gh-2070)" }
+2015-04-16T18:02:50.321974+00:00 host sshd[2716]: pam_unix(sshd:auth): authentication failure; logname= uid=0 euid=0 tty=ssh ruser= rhost=192.0.2.112 user=root
+# failJSON: { "time": "2015-04-16T20:02:50", "match": true , "host": "192.0.2.112", "desc": "Should catch failure - no success/no accepted password" }
+2015-04-16T18:02:50.568798+00:00 host sshd[2716]: Connection closed by 192.0.2.112
+
+# disable this case for obsolete multi-line filter (zzz-sshd-obsolete...):
+# filterOptions: [{"test.condition": "name == 'sshd'"}]
+
+# 2 methods auth: pam_unix and pam_ldap are used in combination (gh-2070), succeeded after "failure" in first method:
+# failJSON: { "match": false , "desc": "No failure" }
+Mar 7 18:53:20 bar sshd[1556]: pam_unix(sshd:auth): authentication failure; logname= uid=0 euid=0 tty=ssh ruser= rhost=192.0.2.113 user=rda
+# failJSON: { "match": false , "desc": "No failure" }
+Mar 7 18:53:20 bar sshd[1556]: pam_unix(sshd:auth): authentication failure; logname= uid=0 euid=0 tty=ssh ruser=rda rhost=192.0.2.113 [preauth]
+# failJSON: { "match": false , "desc": "No failure" }
+Mar 7 18:53:20 bar sshd[1556]: Accepted password for rda from 192.0.2.113 port 52100 ssh2
+# failJSON: { "match": false , "desc": "No failure" }
+Mar 7 18:53:20 bar sshd[1556]: pam_unix(sshd:session): session opened for user rda by (uid=0)
+# failJSON: { "match": false , "desc": "No failure" }
+Mar 7 18:53:20 bar sshd[1556]: Connection closed by 192.0.2.113
+
+# several attempts, intruder tries to "forget" failed attempts by success login (all 3 attempts with different users):
+# failJSON: { "match": false , "desc": "Still no failure (first try)" }
+Mar 7 18:53:22 bar sshd[1558]: pam_unix(sshd:auth): authentication failure; logname= uid=0 euid=0 tty=ssh ruser=root rhost=192.0.2.114
+# failJSON: { "time": "2005-03-07T18:53:23", "match": true , "attempts": 2, "users": ["root", "sudoer"], "host": "192.0.2.114", "desc": "Failure: attempt 2nd user" }
+Mar 7 18:53:23 bar sshd[1558]: pam_unix(sshd:auth): authentication failure; logname= uid=0 euid=0 tty=ssh ruser=sudoer rhost=192.0.2.114
+# failJSON: { "time": "2005-03-07T18:53:24", "match": true , "attempts": 2, "users": ["root", "sudoer", "known"], "host": "192.0.2.114", "desc": "Failure: attempt 3rd user" }
+Mar 7 18:53:24 bar sshd[1558]: Accepted password for known from 192.0.2.114 port 52100 ssh2
+# failJSON: { "match": false , "desc": "No failure" }
+Mar 7 18:53:24 bar sshd[1558]: pam_unix(sshd:session): session opened for user known by (uid=0)
+
+# several attempts, intruder tries to "forget" failed attempts by success login (accepted for other user as in first failed attempt):
+# failJSON: { "match": false , "desc": "Still no failure (first try)" }
+Mar 7 18:53:32 bar sshd[1559]: pam_unix(sshd:auth): authentication failure; logname= uid=0 euid=0 tty=ssh ruser=root rhost=192.0.2.116
+# failJSON: { "match": false , "desc": "Still no failure (second try, same user)" }
+Mar 7 18:53:32 bar sshd[1559]: pam_unix(sshd:auth): authentication failure; logname= uid=0 euid=0 tty=ssh ruser=root rhost=192.0.2.116
+# failJSON: { "time": "2005-03-07T18:53:34", "match": true , "attempts": 2, "users": ["root", "known"], "host": "192.0.2.116", "desc": "Failure: attempt 2nd user" }
+Mar 7 18:53:34 bar sshd[1559]: Accepted password for known from 192.0.2.116 port 52100 ssh2
+# failJSON: { "match": false , "desc": "No failure" }
+Mar 7 18:53:38 bar sshd[1559]: Connection closed by 192.0.2.116
# filterOptions: [{"mode": "ddos"}, {"mode": "aggressive"}]
diff --git a/fail2ban/tests/samplestestcase.py b/fail2ban/tests/samplestestcase.py
index 90ce0b7f..3322b5b0 100644
--- a/fail2ban/tests/samplestestcase.py
+++ b/fail2ban/tests/samplestestcase.py
@@ -151,6 +151,7 @@ def testSampleRegexsFactory(name, basedir):
logFile = fileinput.FileInput(os.path.join(TEST_FILES_DIR, "logs",
filename))
+ ignoreBlock = False
for line in logFile:
jsonREMatch = re.match("^#+ ?(failJSON|filterOptions|addFILE):(.+)$", line)
if jsonREMatch:
@@ -160,9 +161,13 @@ def testSampleRegexsFactory(name, basedir):
if jsonREMatch.group(1) == 'filterOptions':
# following lines with another filter options:
self._filterTests = []
+ ignoreBlock = False
for opts in (faildata if isinstance(faildata, list) else [faildata]):
# unique filter name (using options combination):
self.assertTrue(isinstance(opts, dict))
+ if opts.get('test.condition'):
+ ignoreBlock = not eval(opts.get('test.condition'))
+ del opts['test.condition']
fltName = opts.get('filterName')
if not fltName: fltName = str(opts) if opts else ''
fltName = name + fltName
@@ -179,10 +184,11 @@ def testSampleRegexsFactory(name, basedir):
raise ValueError("%s: %s:%i" %
(e, logFile.filename(), logFile.filelineno()))
line = next(logFile)
- elif line.startswith("#") or not line.strip():
+ elif ignoreBlock or line.startswith("#") or not line.strip():
continue
else: # pragma: no cover - normally unreachable
faildata = {}
+ if ignoreBlock: continue
# if filter options was not yet specified:
if not self._filterTests:
@@ -224,9 +230,17 @@ def testSampleRegexsFactory(name, basedir):
for k, v in faildata.iteritems():
if k not in ("time", "match", "desc", "filter"):
fv = fail.get(k, None)
- # Fallback for backwards compatibility (previously no fid, was host only):
- if k == "host" and fv is None:
- fv = fid
+ if fv is None:
+ # Fallback for backwards compatibility (previously no fid, was host only):
+ if k == "host":
+ fv = fid
+ # special case for attempts counter:
+ if k == "attempts":
+ fv = len(fail.get('matches', {}))
+ # compare sorted (if set)
+ if isinstance(fv, (set, list, dict)):
+ self.assertSortedEqual(fv, v)
+ continue
self.assertEqual(fv, v)
t = faildata.get("time", None)