diff --git a/kippo.cfg.dist b/kippo.cfg.dist index 7ed1b0d3..00cb518e 100644 --- a/kippo.cfg.dist +++ b/kippo.cfg.dist @@ -69,6 +69,24 @@ filesystem_file = fs.pickle # (default: data_path) data_path = data +# Class that implements the checklogin() method. +# +# Class must be defined in kippo/core/auth.py +# Default is the 'UserDB' class which uses the password database. +# +# Alternatively the 'AuthRandom' class can be used, which will let +# a user login after a random number of attempts. +# It will also cache username/password combinations that allow login. +# +auth_class = UserDB +# When AuthRandom is used also set the +# auth_class_parameters: , , +# for example: 2, 5, 10 = allows access after randint(2,5) attempts +# and cache 10 combinations. +# +#auth_class = AuthRandom +#auth_class_parameters = 2, 5, 10 + # Directory for creating simple commands that only output text. # # The command must be placed under this directory with the proper path, such diff --git a/kippo/core/auth.py b/kippo/core/auth.py index 7173b631..ea17413f 100644 --- a/kippo/core/auth.py +++ b/kippo/core/auth.py @@ -2,13 +2,18 @@ # See the COPYRIGHT file for more information import string +import json +from os import path +from sys import modules +from random import randint from zope.interface import implementer import twisted from twisted.cred.checkers import ICredentialsChecker -from twisted.cred.credentials import IUsernamePassword, ISSHPrivateKey, IPluggableAuthenticationModules +from twisted.cred.credentials import IUsernamePassword, ISSHPrivateKey, \ + IPluggableAuthenticationModules, ICredentials from twisted.cred.error import UnauthorizedLogin, UnhandledCredentials from twisted.internet import defer @@ -28,8 +33,7 @@ class UserDB(object): def load(self): '''load the user db''' - userdb_file = '%s/userdb.txt' % \ - (config().get('honeypot', 'data_path'),) + userdb_file = '%s/userdb.txt' % (config().get('honeypot', 'data_path'),) f = open(userdb_file, 'r') while True: @@ -41,7 +45,7 @@ class UserDB(object): if not line: continue - if line.startswith( '#' ): + if line.startswith('#'): continue (login, uid_str, passwd) = line.split(':', 2) @@ -59,8 +63,7 @@ class UserDB(object): def save(self): '''save the user db''' - userdb_file = '%s/userdb.txt' % \ - (config().get('honeypot', 'data_path'),) + userdb_file = '%s/userdb.txt' % (config().get('honeypot', 'data_path'),) # Note: this is subject to races between kippo instances, but hey ... f = open(userdb_file, 'w') @@ -68,14 +71,14 @@ class UserDB(object): f.write('%s:%d:%s\n' % (login, uid, passwd)) f.close() - def checklogin(self, thelogin, thepasswd): + def checklogin(self, thelogin, thepasswd, src_ip = '0.0.0.0'): '''check entered username/password against database''' '''note that it allows multiple passwords for a single username''' '''it also knows wildcard '*' for any password''' '''prepend password with ! to explicitly deny it. Denials must come before wildcards''' for (login, uid, passwd) in self.userdb: # explicitly fail on !password - if login == thelogin and passwd == '!'+thepasswd: + if login == thelogin and passwd == '!' + thepasswd: return False if login == thelogin and passwd in (thepasswd, '*'): return True @@ -114,6 +117,121 @@ class UserDB(object): self.userdb.append((login, uid, passwd)) self.save() +class AuthRandom(object): + """ + Alternative class that defines the checklogin() method. + Users will be authenticated after a random number of attempts. + """ + + def __init__(self, parameters): + # Default values + self.mintry, self.maxtry, self.maxcache = 2, 5, 10 + parlist = parameters.split(',') + + if len(parlist) == 3: + self.mintry = int(parlist[0]) + self.maxtry = int(parlist[1]) + self.maxcache = int(parlist[2]) + if self.maxtry < self.mintry: + self.maxtry = self.mintry + 1 + log.msg('maxtry < mintry, adjusting maxtry to: %d' % self.maxtry) + self.uservar = {} + self.loadvars() + + def loadvars(self): + # Load user vars from json file + uservar_file = '%s/uservar.json' % (config().get('honeypot', 'data_path')) + if path.isfile(uservar_file): + with open(uservar_file, 'rb') as fp: + try: + self.uservar = json.load(fp) + except: + self.uservar = {} + + def savevars(self): + # Save the user vars to json file + uservar_file = '%s/uservar.json' % (config().get('honeypot', 'data_path')) + data = self.uservar + # Note: this is subject to races between kippo logins + with open(uservar_file, 'wb') as fp: + json.dump(data, fp) + + def checklogin(self, thelogin, thepasswd, src_ip): + '''Every new source IP will have to try a random number of times between''' + ''''mintry' and 'maxtry' before succeeding to login.''' + '''All username/password combinations must be different.''' + '''The successful login combination is stored with the IP address.''' + '''Successful username/passwords pairs are also cached for 'maxcache' times.''' + '''This is to allow access for returns from different IP addresses.''' + '''Variables are saved in 'uservar.json' in the data directory.''' + auth = False + userpass = thelogin + ':' + thepasswd + + if not 'cache' in self.uservar: + self.uservar['cache'] = [] + cache = self.uservar['cache'] + + # Check if it is the first visit from src_ip + if src_ip not in self.uservar: + self.uservar[src_ip] = {} + ipinfo = self.uservar[src_ip] + ipinfo['try'] = 0 + if userpass in cache: + log.msg('first time for %s, found cached: %s' % (src_ip, userpass)) + ipinfo['max'] = 1 + ipinfo['user'] = thelogin + ipinfo['pw'] = thepasswd + auth = True + self.savevars() + return auth + else: + ipinfo['max'] = randint(self.mintry, self.maxtry) + log.msg('first time for %s, need: %d' % (src_ip, ipinfo['max'])) + + ipinfo = self.uservar[src_ip] + + # Fill in missing variables + if not 'max' in ipinfo: + ipinfo['max'] = randint(self.mintry, self.maxtry) + if not 'try' in ipinfo: + ipinfo['try'] = 0 + if not 'tried' in ipinfo: + ipinfo['tried'] = [] + + # Don't count repeated username/password combinations + if userpass in ipinfo['tried']: + log.msg('already tried this combination') + self.savevars() + return auth + + ipinfo['try'] += 1 + attempts = ipinfo['try'] + need = ipinfo['max'] + log.msg('login attempt: %d' % attempts) + + # Check if enough login attempts are tried + if attempts < need: + self.uservar[src_ip]['tried'].append(userpass) + elif attempts == need: + ipinfo['user'] = thelogin + ipinfo['pw'] = thepasswd + cache.append(userpass) + if len(cache) > self.maxcache: + cache.pop(0) + auth = True + # Returning after successful login + elif attempts > need: + if not 'user' in ipinfo or not 'pw' in ipinfo: + log.msg('return, but username or password not set!!!') + ipinfo['tried'].append(userpass) + ipinfo['try'] = 1 + else: + log.msg('login return, expect: [%s/%s]' % (ipinfo['user'], ipinfo['pw'])) + if thelogin == ipinfo['user'] and thepasswd == ipinfo['pw']: + auth = True + self.savevars() + return auth + @implementer(ICredentialsChecker) class HoneypotPublicKeyChecker: """ @@ -125,8 +243,27 @@ class HoneypotPublicKeyChecker: def requestAvatarId(self, credentials): _pubKey = keys.Key.fromString(credentials.blob) log.msg(format='public key attempt for user %(username)s with fingerprint %(fingerprint)s', - username=credentials.username, fingerprint=_pubKey.fingerprint()) - return failure.Failure(error.ConchError("Incorrect signature")) + username=credentials.username, + fingerprint=_pubKey.fingerprint()) + return failure.Failure(error.ConchError('Incorrect signature')) + +# This credential interface also provides an IP address +@implementer(IUsernamePassword) +class UsernamePasswordIP: + + def __init__(self, username, password, ip): + self.username = username + self.password = password + self.ip = ip + +# This credential interface also provides an IP address +@implementer(IPluggableAuthenticationModules) +class PluggableAuthenticationModulesIP: + + def __init__(self, username, pamConversion, ip): + self.username = username + self.pamConversion = pamConversion + self.ip = ip @implementer(ICredentialsChecker) class HoneypotPasswordChecker: @@ -138,37 +275,59 @@ class HoneypotPasswordChecker: def requestAvatarId(self, credentials): if hasattr(credentials, 'password'): - if self.checkUserPass(credentials.username, credentials.password): + if self.checkUserPass(credentials.username, credentials.password, + credentials.ip): return defer.succeed(credentials.username) else: return defer.fail(UnauthorizedLogin()) elif hasattr(credentials, 'pamConversion'): return self.checkPamUser(credentials.username, - credentials.pamConversion) + credentials.pamConversion, credentials.ip) return defer.fail(UnhandledCredentials()) - def checkPamUser(self, username, pamConversion): + def checkPamUser(self, username, pamConversion, ip): r = pamConversion((('Password:', 1),)) - return r.addCallback(self.cbCheckPamUser, username) + return r.addCallback(self.cbCheckPamUser, username, ip) - def cbCheckPamUser(self, responses, username): - for response, zero in responses: - if self.checkUserPass(username, response): + def cbCheckPamUser(self, responses, username, ip): + for (response, zero) in responses: + if self.checkUserPass(username, response, ip): return defer.succeed(username) return defer.fail(UnauthorizedLogin()) - def checkUserPass(self, theusername, thepassword): - if UserDB().checklogin(theusername, thepassword): - #log.msg( 'login attempt [%s/%s] succeeded' % (theusername, thepassword) ) - log.msg( eventid='KIPP0002', + def checkUserPass(self, theusername, thepassword, ip): + # UserDB is the default auth_class + authname = UserDB + parameters = None + + # Is the auth_class defined in the config file? + if config().has_option('honeypot', 'auth_class'): + authclass = config().get('honeypot', 'auth_class') + + # Check if authclass exists in this module + if hasattr(modules[__name__], authclass): + authname = getattr(modules[__name__], authclass) + + # Are there auth_class parameters? + if config().has_option('honeypot', 'auth_class_parameters'): + parameters = config().get('honeypot', 'auth_class_parameters') + else: + log.msg('auth_class: %s not found in %s' % (authclass, __name__)) + + if parameters: + theauth = authname(parameters) + else: + theauth = authname() + + if theauth.checklogin(theusername, thepassword, ip): + log.msg(eventid='KIPP0002', format='login attempt [%(username)s/%(password)s] succeeded', - username=theusername, password=thepassword ) + username=theusername, password=thepassword) return True else: - #log.msg( 'login attempt [%s/%s] failed' % (theusername, thepassword) ) - log.msg( eventid='KIPP0003', + log.msg(eventid='KIPP0003', format='login attempt [%(username)s/%(password)s] failed', - username=theusername, password=thepassword ) + username=theusername, password=thepassword) return False # vim: set sw=4 et: diff --git a/kippo/core/ssh.py b/kippo/core/ssh.py index 1856d9e0..cb38fc22 100644 --- a/kippo/core/ssh.py +++ b/kippo/core/ssh.py @@ -56,6 +56,25 @@ class HoneyPotSSHUserAuthServer(userauth.SSHUserAuthServer): self.sendBanner() return userauth.SSHUserAuthServer.ssh_USERAUTH_REQUEST(self, packet) + # Overridden to pass src_ip to auth.UsernamePasswordIP + def auth_password(self, packet): + password = getNS(packet[1:])[0] + src_ip = self.transport.transport.getPeer().host + c = auth.UsernamePasswordIP(self.user, password, src_ip) + return self.portal.login(c, None, conchinterfaces.IConchUser).addErrback( + self._ebPassword) + + # Overridden to pass src_ip to auth.PluggableAuthenticationModulesIP + def auth_keyboard_interactive(self, packet): + if self._pamDeferred is not None: + self.transport.sendDisconnect( + transport.DISCONNECT_PROTOCOL_ERROR, + "only one keyboard interactive attempt at a time") + return defer.fail(error.IgnoreAuthentication()) + src_ip = self.transport.transport.getPeer().host + c = auth.PluggableAuthenticationModulesIP(self.user, self._pamConv, src_ip) + return self.portal.login(c, None, conchinterfaces.IConchUser) + # As implemented by Kojoney class HoneyPotSSHFactory(factory.SSHFactory): services = {