From 25cc42129a16b3698cc6529be8a45a8f38946629 Mon Sep 17 00:00:00 2001 From: sebres Date: Tue, 20 Mar 2018 13:09:05 +0100 Subject: [PATCH] hold all user names affected by interim attempts in order to avoid forget a failures after success login: intruder (as legitimate user) firstly tries to login with another user-name (brute-force), so hopes to reset failure counter by succeeded login; this is fixed and covered in tests now; sshd-filter extended to cover multiple-login attempts (also fully implements gh-2070); --- config/filter.d/sshd.conf | 7 +- fail2ban/server/filter.py | 68 ++++++++++++++----- .../filter.d/zzz-sshd-obsolete-multiline.conf | 5 +- fail2ban/tests/files/logs/sshd | 45 ++++++++++-- fail2ban/tests/samplestestcase.py | 22 ++++-- 5 files changed, 118 insertions(+), 29 deletions(-) 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)