From cc7d65adc0c9e1be1dcbb670c26ffa34e5238f95 Mon Sep 17 00:00:00 2001 From: Guilherme Borges Date: Sat, 29 Jun 2019 10:20:26 +0100 Subject: [PATCH] Add an SSH proxy for Cowrie (#1154) * add SSH proxy --- etc/cowrie.cfg.dist | 9 + src/cowrie/core/config.py | 4 +- src/cowrie/core/realm.py | 13 +- src/cowrie/proxy/avatar.py | 43 - src/cowrie/proxy/endpoints.py | 1136 ----------------- src/cowrie/proxy/session.py | 260 ---- src/cowrie/ssh/factory.py | 18 +- src/cowrie/ssh/transport.py | 3 + src/cowrie/ssh_proxy/README.md | 44 + src/cowrie/{proxy => ssh_proxy}/__init__.py | 0 src/cowrie/ssh_proxy/client_transport.py | 166 +++ src/cowrie/ssh_proxy/protocols/__init__.py | 0 .../ssh_proxy/protocols/base_protocol.py | 84 ++ src/cowrie/ssh_proxy/protocols/exec_term.py | 87 ++ .../protocols/port_forward.py} | 24 +- src/cowrie/ssh_proxy/protocols/sftp.py | 312 +++++ src/cowrie/ssh_proxy/protocols/ssh.py | 362 ++++++ src/cowrie/ssh_proxy/protocols/term.py | 184 +++ src/cowrie/ssh_proxy/server_transport.py | 397 ++++++ src/cowrie/ssh_proxy/userauth.py | 34 + src/cowrie/ssh_proxy/util.py | 16 + 21 files changed, 1725 insertions(+), 1471 deletions(-) delete mode 100644 src/cowrie/proxy/avatar.py delete mode 100644 src/cowrie/proxy/endpoints.py delete mode 100644 src/cowrie/proxy/session.py create mode 100644 src/cowrie/ssh_proxy/README.md rename src/cowrie/{proxy => ssh_proxy}/__init__.py (100%) create mode 100644 src/cowrie/ssh_proxy/client_transport.py create mode 100644 src/cowrie/ssh_proxy/protocols/__init__.py create mode 100644 src/cowrie/ssh_proxy/protocols/base_protocol.py create mode 100644 src/cowrie/ssh_proxy/protocols/exec_term.py rename src/cowrie/{proxy/server.py => ssh_proxy/protocols/port_forward.py} (66%) create mode 100644 src/cowrie/ssh_proxy/protocols/sftp.py create mode 100644 src/cowrie/ssh_proxy/protocols/ssh.py create mode 100644 src/cowrie/ssh_proxy/protocols/term.py create mode 100644 src/cowrie/ssh_proxy/server_transport.py create mode 100644 src/cowrie/ssh_proxy/userauth.py create mode 100644 src/cowrie/ssh_proxy/util.py diff --git a/etc/cowrie.cfg.dist b/etc/cowrie.cfg.dist index c63e550c..079cba3d 100644 --- a/etc/cowrie.cfg.dist +++ b/etc/cowrie.cfg.dist @@ -191,7 +191,16 @@ auth_class = UserDB # Source Port to report in logs (useful if you use iptables to forward ports to Cowrie) #reported_ssh_port = 22 +# ============================================================================ +# Proxy Options +# ============================================================================ +[proxy] +backend_ssh_host = localhost +backend_ssh_port = 2022 + +backend_user = root +backend_pass = root # ============================================================================ # Shell Options diff --git a/src/cowrie/core/config.py b/src/cowrie/core/config.py index 638cd87f..8ee5033a 100644 --- a/src/cowrie/core/config.py +++ b/src/cowrie/core/config.py @@ -39,11 +39,11 @@ class EnvironmentConfigParser(configparser.ConfigParser): return True return super(EnvironmentConfigParser, self).has_option(section, option) - def get(self, section, option, **kwargs): + def get(self, section, option, raw=False, **kwargs): key = to_environ_key('_'.join((section, option))) if key in environ: return environ[key] - return super(EnvironmentConfigParser, self).get(section, option, **kwargs) + return super(EnvironmentConfigParser, self).get(section, option, raw=raw, **kwargs) def readConfigFile(cfgfile): diff --git a/src/cowrie/core/realm.py b/src/cowrie/core/realm.py index f3290210..56447248 100644 --- a/src/cowrie/core/realm.py +++ b/src/cowrie/core/realm.py @@ -36,8 +36,6 @@ from twisted.python import log from zope.interface import implementer from cowrie.core.config import CowrieConfig -from cowrie.proxy import avatar as proxyavatar -from cowrie.proxy import server as proxyserver from cowrie.shell import avatar as shellavatar from cowrie.shell import server as shellserver from cowrie.telnet import session @@ -61,15 +59,10 @@ class HoneyPotRealm(object): serv = shellserver.CowrieServer(self) user = session.HoneyPotTelnetSession(avatarId, serv) return interfaces[0], user, user.logout - raise NotImplementedError("No supported interfaces found.") - elif backend == 'proxy': - if conchinterfaces.IConchUser in interfaces: - serv = proxyserver.CowrieServer(self) - user = proxyavatar.CowrieUser(avatarId, serv) - return interfaces[0], user, user.logout - elif ITelnetProtocol in interfaces: - raise NotImplementedError("Telnet not yet supported for proxy mode.") log.msg('No supported interfaces found.') raise NotImplementedError("No supported interfaces found.") + elif backend == 'proxy': + # not used in proxies, as they operate on a lower level + pass else: raise NotImplementedError("No supported backend found.") diff --git a/src/cowrie/proxy/avatar.py b/src/cowrie/proxy/avatar.py deleted file mode 100644 index 143d49fe..00000000 --- a/src/cowrie/proxy/avatar.py +++ /dev/null @@ -1,43 +0,0 @@ -# Copyright (c) 2009-2014 Upi Tamminen -# See the COPYRIGHT file for more information - -""" -This module contains ... -""" - -from __future__ import absolute_import, division - -from twisted.conch import avatar -from twisted.conch.interfaces import IConchUser, ISession -from twisted.python import components, log - -from zope.interface import implementer - -from cowrie.core.config import CowrieConfig -from cowrie.proxy import session as proxysession -from cowrie.shell import session as shellsession -from cowrie.ssh import forwarding - - -@implementer(IConchUser) -class CowrieUser(avatar.ConchUser): - - def __init__(self, username, server): - avatar.ConchUser.__init__(self) - self.username = username - self.server = server - - self.channelLookup[b'session'] = proxysession.ProxySSHSession - - # TODO: Is SFTP still supported? Check git commit 30949e0 for cleaned up code - - # SSH forwarding disabled only when option is explicitly set - self.channelLookup[b'direct-tcpip'] = forwarding.cowrieOpenConnectForwardingClient - if CowrieConfig().getboolean('ssh', 'forwarding', fallback=False) is False: - del self.channelLookup[b'direct-tcpip'] - - def logout(self): - log.msg('avatar {} logging out'.format(self.username)) - - -components.registerAdapter(shellsession.SSHSessionForCowrieUser, CowrieUser, ISession) diff --git a/src/cowrie/proxy/endpoints.py b/src/cowrie/proxy/endpoints.py deleted file mode 100644 index c4426bc2..00000000 --- a/src/cowrie/proxy/endpoints.py +++ /dev/null @@ -1,1136 +0,0 @@ -# -*- test-case-name: twisted.conch.test.test_endpoints -*- -# Copyright (c) Twisted Matrix Laboratories. -# See LICENSE for details. - -""" -Endpoint implementations of various SSH interactions. -""" - -__all__ = [ - 'AuthenticationFailed', 'SSHCommandAddress', 'SSHCommandClientEndpoint'] - -from os.path import expanduser -from struct import unpack - -from twisted.conch.client.agent import SSHAgentClient -from twisted.conch.client.default import _KNOWN_HOSTS -from twisted.conch.client.knownhosts import ConsoleUI, KnownHostsFile -from twisted.conch.ssh.channel import SSHChannel -from twisted.conch.ssh.common import NS -from twisted.conch.ssh.connection import SSHConnection -from twisted.conch.ssh.keys import Key -from twisted.conch.ssh.session import SSHSession, packRequest_pty_req -from twisted.conch.ssh.transport import SSHClientTransport -from twisted.conch.ssh.userauth import SSHUserAuthClient -from twisted.internet.defer import CancelledError, Deferred, succeed -from twisted.internet.endpoints import TCP4ClientEndpoint, connectProtocol -from twisted.internet.error import ConnectionDone, ProcessTerminated -from twisted.internet.interfaces import IStreamClientEndpoint -from twisted.internet.protocol import Factory -from twisted.python import log -from twisted.python.compat import nativeString, networkString -from twisted.python.failure import Failure -from twisted.python.filepath import FilePath - -from zope.interface import Interface, implementer - - -class AuthenticationFailed(Exception): - """ - An SSH session could not be established because authentication was not - successful. - """ - - -# This should be public. See #6541. -class ICowrieSSHConnectionCreator(Interface): - """ - An L{ICowrieSSHConnectionCreator} knows how to create SSH connections somehow. - """ - - def secureConnection(): - """ - Return a new, connected, secured, but not yet authenticated instance of - L{twisted.conch.ssh.transport.SSHServerTransport} or - L{twisted.conch.ssh.transport.SSHClientTransport}. - """ - - def cleanupConnection(connection, immediate): - """ - Perform cleanup necessary for a connection object previously returned - from this creator's C{secureConnection} method. - - @param connection: An L{twisted.conch.ssh.transport.SSHServerTransport} - or L{twisted.conch.ssh.transport.SSHClientTransport} returned by a - previous call to C{secureConnection}. It is no longer needed by the - caller of that method and may be closed or otherwise cleaned up as - necessary. - - @param immediate: If C{True} don't wait for any network communication, - just close the connection immediately and as aggressively as - necessary. - """ - - -class SSHCommandAddress(object): - """ - An L{SSHCommandAddress} instance represents the address of an SSH server, a - username which was used to authenticate with that server, and a command - which was run there. - - @ivar server: See L{__init__} - @ivar username: See L{__init__} - @ivar command: See L{__init__} - """ - - def __init__(self, server, username, command): - """ - @param server: The address of the SSH server on which the command is - running. - @type server: L{IAddress} provider - - @param username: An authentication username which was used to - authenticate against the server at the given address. - @type username: L{bytes} - - @param command: A command which was run in a session channel on the - server at the given address. - @type command: L{bytes} - """ - self.server = server - self.username = username - self.command = command - - -class SSHShellAddress(object): - """ - An L{SSHShellAddress} instance represents the address of an SSH server and - a username which was used to authenticate with that server - - @ivar server: See L{__init__} - @ivar username: See L{__init__} - """ - - def __init__(self, server, username): - """ - @param server: The address of the SSH server on which the command is - running. - @type server: L{IAddress} provider - - @param username: An authentication username which was used to - authenticate against the server at the given address. - @type username: L{bytes} - - """ - self.server = server - self.username = username - - -class _CommandChannel(SSHChannel): - """ - A L{_CommandChannel} executes a command in a session channel and connects - its input and output to an L{IProtocol} provider. - - @ivar _creator: See L{__init__} - @ivar _command: See L{__init__} - @ivar _protocolFactory: See L{__init__} - @ivar _commandConnected: See L{__init__} - @ivar _protocol: An L{IProtocol} provider created using C{_protocolFactory} - which is hooked up to the running command's input and output streams. - """ - name = b'proxy-backend-session' - name = b'session' - - def __init__(self, creator, command, protocolFactory, commandConnected): - """ - @param creator: The L{ICowrieSSHConnectionCreator} provider which was used - to get the connection which this channel exists on. - @type creator: L{ICowrieSSHConnectionCreator} provider - - @param command: The command to be executed. - @type command: L{bytes} - - @param protocolFactory: A client factory to use to build a L{IProtocol} - provider to use to associate with the running command. - - @param commandConnected: A L{Deferred} to use to signal that execution - of the command has failed or that it has succeeded and the command - is now running. - @type commandConnected: L{Deferred} - """ - SSHChannel.__init__(self) - self._creator = creator - self._command = command - self._protocolFactory = protocolFactory - self._commandConnected = commandConnected - self._reason = None - - def openFailed(self, reason): - """ - When the request to open a new channel to run this command in fails, - fire the C{commandConnected} deferred with a failure indicating that. - """ - self._commandConnected.errback(reason) - - def channelOpen(self, ignored): - """ - When the request to open a new channel to run this command in succeeds, - issue an C{"exec"} request to run the command. - """ - command = self.conn.sendRequest( - self, b'exec', NS(self._command), wantReply=True) - command.addCallbacks(self._execSuccess, self._execFailure) - - def _execFailure(self, reason): - """ - When the request to execute the command in this channel fails, fire the - C{commandConnected} deferred with a failure indicating this. - - @param reason: The cause of the command execution failure. - @type reason: L{Failure} - """ - self._commandConnected.errback(reason) - - def _execSuccess(self, ignored): - """ - When the request to execute the command in this channel succeeds, use - C{protocolFactory} to build a protocol to handle the command's input and - output and connect the protocol to a transport representing those - streams. - - Also fire C{commandConnected} with the created protocol after it is - connected to its transport. - - @param ignored: The (ignored) result of the execute request - """ - - log.msg("command = {0}".format(self._command)) - - self._protocol = self._protocolFactory.buildProtocol( - SSHCommandAddress( - self.conn.transport.transport.getPeer(), - self.conn.transport.creator.username, - self._command)) - self._protocol.makeConnection(self) - self._commandConnected.callback(self._protocol) - - def dataReceived(self, data): - """ - When the command's stdout data arrives over the channel, deliver it to - the protocol instance. - - @param data: The bytes from the command's stdout. - @type data: L{bytes} - """ - log.msg("command output received{0}".format(repr(data))) - self._protocol.dataReceived(data) - - def request_exit_status(self, data): - """ - When the server sends the command's exit status, record it for later - delivery to the protocol. - - @param data: The network-order four byte representation of the exit - status of the command. - @type data: L{bytes} - """ - (status,) = unpack('>L', data) - if status != 0: - self._reason = ProcessTerminated(status, None, None) - - def request_exit_signal(self, data): - """ - When the server sends the command's exit status, record it for later - delivery to the protocol. - - @param data: The network-order four byte representation of the exit - signal of the command. - @type data: L{bytes} - """ - (signal,) = unpack('>L', data) - self._reason = ProcessTerminated(None, signal, None) - - def closed(self): - """ - When the channel closes, deliver disconnection notification to the - protocol. - """ - self._creator.cleanupConnection(self.conn, False) - if self._reason is None: - reason = ConnectionDone("ssh channel closed") - else: - reason = self._reason - self._protocol.connectionLost(Failure(reason)) - - -class _ShellChannel(SSHSession): - """ - A L{_ShellChannel} opens a shell channel and connects - its input and output to an L{IProtocol} provider. - - @ivar _creator: See L{__init__} - @ivar _protocolFactory: See L{__init__} - @ivar _shellConnected: See L{__init__} - @ivar _protocol: An L{IProtocol} provider created using C{_protocolFactory} - which is hooked up to the shell's input and output streams. - """ - name = b'session' - connected = False - - def __init__(self, creator, protocolFactory, shellConnected): - """ - @param creator: The L{ICowrieSSHConnectionCreator} provider which was used - to get the connection which this channel exists on. - @type creator: L{ICowrieSSHConnectionCreator} provider - - @param protocolFactory: A client factory to use to build a L{IProtocol} - provider to use to associate with the shell - - @param shellConnected: A L{Deferred} to use to signal that opening the - shell has failed or that it has succeeded and the shell is open. - @type shellConnected: L{Deferred} - """ - SSHChannel.__init__(self) - self._creator = creator - self._protocolFactory = protocolFactory - self._shellConnected = shellConnected - self._reason = None - - def openFailed(self, reason): - """ - When the request to open a new channel to open this shell fails, - fire the C{shellConnected} deferred with a failure indicating that. - """ - self._shellConnected.errback(reason) - - def ttyRequest(self, something): - log.msg("tty-request") - term = "vt100" - winSize = (25, 80, 0, 0) # struct.unpack('4H', winsz) - ptyReqData = packRequest_pty_req(term, winSize, '') - req = self.conn.sendRequest(self, b'pty-req', ptyReqData, wantReply=True) - req.addCallback(self.shellRequest) - - def channelOpen(self, ignored): - """ - When the request to open a new channel in succeeds, - issue a C{"shell"} request. - """ - command = self.conn.sendRequest(self, b'shell', b'', wantReply=True) - command.addCallbacks(self._shellSuccess, self._shellFailure) - - def _shellFailure(self, reason): - """ - When the request to open a shell in this channel fails, fire the - C{shellConnected} deferred with a failure indicating this. - - @param reason: The cause of the shell opening failure. - @type reason: L{Failure} - """ - log.msg("failed to connect to proxy backend: {0}".format(reason)) - self._shellConnected.errback(reason) - - def _shellSuccess(self, ignored): - """ - When the request to open this shell this channel succeeds, use - C{protocolFactory} to build a protocol to handle the shell's input and - output and connect the protocol to a transport representing those - streams. - - Also fire C{shellConnected} with the created protocol after it is - connected to its transport. - - @param ignored: The (ignored) result of the execute request - """ - log.msg("connected to proxy backend") - self.connected = True - self._protocol = self._protocolFactory.buildProtocol( - SSHShellAddress( - self.conn.transport.transport.getPeer(), - self.conn.transport.creator.username)) - self._protocol.makeConnection(self) - self._shellConnected.callback(self._protocol) - - def dataReceived(self, data): - """ - When the shell's stdout data arrives over the channel, deliver it to - the protocol instance. - - @param data: The bytes from the command's stdout. - @type data: L{bytes} - """ - log.msg("client-session dataReceived: {0}".format(repr(data))) - self._protocol.dataReceived(data) - - def request_exit_status(self, data): - """ - When the server sends the shell's exit status, record it for later - delivery to the protocol. - - @param data: The network-order four byte representation of the exit - status of the shell. - @type data: L{bytes} - """ - (status,) = unpack('>L', data) - if status != 0: - self._reason = ProcessTerminated(status, None, None) - - def request_exit_signal(self, data): - """ - When the server sends the shell's exit status, record it for later - delivery to the protocol. - - @param data: The network-order four byte representation of the exit - signal of the shell. - @type data: L{bytes} - """ - (signal,) = unpack('>L', data) - self._reason = ProcessTerminated(None, signal, None) - - def closed(self): - """ - When the channel closes, deliver disconnection notification to the - protocol. - """ - self._creator.cleanupConnection(self.conn, False) - if self._reason is None: - reason = ConnectionDone("ssh channel closed") - else: - reason = self._reason - self._protocol.connectionLost(Failure(reason)) - - -class _ConnectionReady(SSHConnection): - """ - L{_ConnectionReady} is an L{SSHConnection} (an SSH service) which only - propagates the I{serviceStarted} event to a L{Deferred} to be handled - elsewhere. - """ - - def __init__(self, ready): - """ - @param ready: A L{Deferred} which should be fired when - I{serviceStarted} happens. - """ - SSHConnection.__init__(self) - self._ready = ready - - def serviceStarted(self): - """ - When the SSH I{connection} I{service} this object represents is ready to - be used, fire the C{connectionReady} L{Deferred} to publish that event - to some other interested party. - - """ - self._ready.callback(self) - del self._ready - - -class _UserAuth(SSHUserAuthClient): - """ - L{_UserAuth} implements the client part of SSH user authentication in the - convenient way a user might expect if they are familiar with the - interactive I{ssh} command line client. - - L{_UserAuth} supports key-based authentication, password-based - authentication, and delegating authentication to an agent. - """ - password = None - keys = None - agent = None - - def getPublicKey(self): - """ - Retrieve the next public key object to offer to the server, possibly - delegating to an authentication agent if there is one. - - @return: The public part of a key pair that could be used to - authenticate with the server, or L{None} if there are no more public - keys to try. - @rtype: L{twisted.conch.ssh.keys.Key} or L{None} - """ - if self.agent is not None: - return self.agent.getPublicKey() - - if self.keys: - self.key = self.keys.pop(0) - else: - self.key = None - return self.key.public() - - def signData(self, publicKey, signData): - """ - Extend the base signing behavior by using an SSH agent to sign the - data, if one is available. - - @type publicKey: L{Key} - @type signData: L{str} - """ - if self.agent is not None: - return self.agent.signData(publicKey.blob(), signData) - else: - return SSHUserAuthClient.signData(self, publicKey, signData) - - def getPrivateKey(self): - """ - Get the private part of a key pair to use for authentication. The key - corresponds to the public part most recently returned from - C{getPublicKey}. - - @return: A L{Deferred} which fires with the private key. - @rtype: L{Deferred} - """ - return succeed(self.key) - - def getPassword(self): - """ - Get the password to use for authentication. - - @return: A L{Deferred} which fires with the password, or L{None} if the - password was not specified. - """ - if self.password is None: - return - return succeed(self.password) - - def ssh_USERAUTH_SUCCESS(self, packet): - """ - Handle user authentication success in the normal way, but also make a - note of the state change on the L{_CommandTransport}. - """ - self.transport._state = b'CHANNELLING' - return SSHUserAuthClient.ssh_USERAUTH_SUCCESS(self, packet) - - def connectToAgent(self, endpoint): - """ - Set up a connection to the authentication agent and trigger its - initialization. - - @param endpoint: An endpoint which can be used to connect to the - authentication agent. - @type endpoint: L{IStreamClientEndpoint} provider - - @return: A L{Deferred} which fires when the agent connection is ready - for use. - """ - factory = Factory() - factory.protocol = SSHAgentClient - d = endpoint.connect(factory) - - def connected(agent): - self.agent = agent - return agent.getPublicKeys() - - d.addCallback(connected) - return d - - def loseAgentConnection(self): - """ - Disconnect the agent. - """ - if self.agent is None: - return - self.agent.transport.loseConnection() - - -class _CommandTransport(SSHClientTransport): - """ - L{_CommandTransport} is an SSH client I{transport} which includes a host key - verification step before it will proceed to secure the connection. - - L{_CommandTransport} also knows how to set up a connection to an - authentication agent if it is told where it can connect to one. - - @ivar _userauth: The L{_UserAuth} instance which is in charge of the - overall authentication process or L{None} if the SSH connection has not - reach yet the C{user-auth} service. - @type _userauth: L{_UserAuth} - """ - # STARTING -> SECURING -> AUTHENTICATING -> CHANNELLING -> RUNNING - _state = b'STARTING' - - _hostKeyFailure = None - - _userauth = None - - def __init__(self, creator): - """ - @param creator: The L{_NewConnectionHelper} that created this - connection. - - @type creator: L{_NewConnectionHelper}. - """ - self.connectionReady = Deferred( - lambda d: self.transport.abortConnection()) - - # Clear the reference to that deferred to help the garbage collector - # and to signal to other parts of this implementation (in particular - # connectionLost) that it has already been fired and does not need to - # be fired again. - def readyFired(result): - self.connectionReady = None - return result - - self.connectionReady.addBoth(readyFired) - self.creator = creator - - def verifyHostKey(self, hostKey, fingerprint): - """ - Ask the L{KnownHostsFile} provider available on the factory which - created this protocol this protocol to verify the given host key. - - @return: A L{Deferred} which fires with the result of - L{KnownHostsFile.verifyHostKey}. - """ - # TODO: re-enable host key check - self._state = b'SECURING' - return succeed(None) - - hostname = self.creator.hostname - ip = networkString(self.transport.getPeer().host) - - self._state = b'SECURING' - d = self.creator.knownHosts.verifyHostKey( - self.creator.ui, hostname, ip, Key.fromString(hostKey)) - d.addErrback(self._saveHostKeyFailure) - return d - - def _saveHostKeyFailure(self, reason): - """ - When host key verification fails, record the reason for the failure in - order to fire a L{Deferred} with it later. - - @param reason: The cause of the host key verification failure. - @type reason: L{Failure} - - @return: C{reason} - @rtype: L{Failure} - """ - self._hostKeyFailure = reason - return reason - - def connectionSecure(self): - """ - When the connection is secure, start the authentication process. - """ - self._state = b'AUTHENTICATING' - - instance = _ConnectionReady(self.connectionReady) - - self._userauth = _UserAuth(self.creator.username, instance) - self._userauth.password = self.creator.password - if self.creator.keys: - self._userauth.keys = list(self.creator.keys) - - if self.creator.agentEndpoint is not None: - d = self._userauth.connectToAgent(self.creator.agentEndpoint) - else: - d = succeed(None) - - def maybeGotAgent(ignored): - self.requestService(self._userauth) - - d.addBoth(maybeGotAgent) - - def connectionLost(self, reason): - """ - When the underlying connection to the SSH server is lost, if there were - any connection setup errors, propagate them. Also, clean up the - connection to the ssh agent if one was created. - """ - if self._userauth: - self._userauth.loseAgentConnection() - - if self._state == b'RUNNING' or self.connectionReady is None: - return - if self._state == b'SECURING' and self._hostKeyFailure is not None: - reason = self._hostKeyFailure - elif self._state == b'AUTHENTICATING': - reason = Failure( - AuthenticationFailed("Connection lost while authenticating")) - self.connectionReady.errback(reason) - - -@implementer(IStreamClientEndpoint) -class SSHShellClientEndpoint(object): - """ - L{SSHShellClientEndpoint} exposes a connection to an SSH server. - - L{SSHShellClientEndpoint} can set up a new SSH connection, authenticate - it in any one of a number of different ways (keys, passwords, agents), - - It can also re-use an existing, already-authenticated SSH connection - (perhaps one which already has some SSH channels being used for other - purposes). In this case it creates a new SSH channel to use to execute the - command. Notably this means it supports multiplexing several different - command invocations over a single SSH connection. - """ - - def __init__(self, creator): - """ - @param creator: An L{ICowrieSSHConnectionCreator} provider which will be - used to set up the SSH connection which will be used to run a - command. - @type creator: L{ICowrieSSHConnectionCreator} provider - """ - self._creator = creator - - @classmethod - def newConnection(cls, reactor, username, hostname, port=None, - keys=None, password=None, agentEndpoint=None, - knownHosts=None, ui=None): - """ - Create and return a new endpoint which will try to create a new - connection to an SSH server and run a command over it. It will also - close the connection if there are problems leading up to the command - being executed, after the command finishes, or if the connection - L{Deferred} is cancelled. - - @param reactor: The reactor to use to establish the connection. - @type reactor: L{IReactorTCP} provider - - @param username: The username with which to authenticate to the SSH - server. - @type username: L{bytes} - - @param hostname: The hostname of the SSH server. - @type hostname: L{bytes} - - @param port: The port number of the SSH server. By default, the - standard SSH port number is used. - @type port: L{int} - - @param keys: Private keys with which to authenticate to the SSH server, - if key authentication is to be attempted (otherwise L{None}). - @type keys: L{list} of L{Key} - - @param password: The password with which to authenticate to the SSH - server, if password authentication is to be attempted (otherwise - L{None}). - @type password: L{bytes} or L{None} - - @param agentEndpoint: An L{IStreamClientEndpoint} provider which may be - used to connect to an SSH agent, if one is to be used to help with - authentication. - @type agentEndpoint: L{IStreamClientEndpoint} provider - - @param knownHosts: The currently known host keys, used to check the - host key presented by the server we actually connect to. - @type knownHosts: L{KnownHostsFile} - - @param ui: An object for interacting with users to make decisions about - whether to accept the server host keys. If L{None}, a L{ConsoleUI} - connected to /dev/tty will be used; if /dev/tty is unavailable, an - object which answers C{b"no"} to all prompts will be used. - @type ui: L{None} or L{ConsoleUI} - - @return: A new instance of C{cls} (probably - L{SSHShellClientEndpoint}). - """ - helper = _NewConnectionHelper( - reactor, hostname, port, username, keys, password, - agentEndpoint, knownHosts, ui) - return cls(helper) - - @classmethod - def existingConnection(cls, connection): - """ - Create and return a new endpoint which will try to open a new channel on - an existing SSH connection and run a command over it. It will B{not} - close the connection if there is a problem executing the command or - after the command finishes. - - @param connection: An existing connection to an SSH server. - @type connection: L{SSHConnection} - - @return: A new instance of C{cls} (probably - L{SSHShellClientEndpoint}). - """ - helper = _ExistingConnectionHelper(connection) - return cls(helper) - - def connect(self, protocolFactory): - """ - Set up an SSH connection, use a channel from that connection to launch - a command, and hook the stdin and stdout of that command up as a - transport for a protocol created by the given factory. - - @param protocolFactory: A L{Factory} to use to create the protocol - which will be connected to the stdin and stdout of the command on - the SSH server. - - @return: A L{Deferred} which will fire with an error if the connection - cannot be set up for any reason or with the protocol instance - created by C{protocolFactory} once it has been connected to the - command. - """ - d = self._creator.secureConnection() - d.addCallback(self._shellCommand, protocolFactory) - return d - - def _shellCommand(self, connection, protocolFactory): - """ - Given a secured SSH connection, try to execute a command in a new - channel created on it and associate the result with a protocol from the - given factory. - - @param connection: See L{SSHCommandClientEndpoint.existingConnection}'s - C{connection} parameter. - - @param protocolFactory: See L{SSHCommandClientEndpoint.connect}'s - C{protocolFactory} parameter. - - @return: See L{SSHCommandClientEndpoint.connect}'s return value. - """ - shellConnected = Deferred() - - def disconnectOnFailure(passthrough): - # Close the connection immediately in case of cancellation, since - # that implies user wants it gone immediately (e.g. a timeout): - immediate = passthrough.check(CancelledError) - self._creator.cleanupConnection(connection, immediate) - return passthrough - - shellConnected.addErrback(disconnectOnFailure) - - channel = _ShellChannel(self._creator, protocolFactory, shellConnected) - connection.openChannel(channel) - return shellConnected - - -@implementer(IStreamClientEndpoint) -class SSHCommandClientEndpoint(object): - """ - L{SSHCommandClientEndpoint} exposes the command-executing functionality of - SSH servers. - - L{SSHCommandClientEndpoint} can set up a new SSH connection, authenticate - it in any one of a number of different ways (keys, passwords, agents), - launch a command over that connection and then associate its input and - output with a protocol. - - It can also re-use an existing, already-authenticated SSH connection - (perhaps one which already has some SSH channels being used for other - purposes). In this case it creates a new SSH channel to use to execute the - command. Notably this means it supports multiplexing several different - command invocations over a single SSH connection. - """ - - def __init__(self, creator, command): - """ - @param creator: An L{ICowrieSSHConnectionCreator} provider which will be - used to set up the SSH connection which will be used to run a - command. - @type creator: L{ICowrieSSHConnectionCreator} provider - - @param command: The command line to execute on the SSH server. This - byte string is interpreted by a shell on the SSH server, so it may - have a value like C{"ls /"}. Take care when trying to run a command - like C{"/Volumes/My Stuff/a-program"} - spaces (and other special - bytes) may require escaping. - @type command: L{bytes} - - """ - self._creator = creator - self._command = command - - @classmethod - def newConnection(cls, reactor, command, username, hostname, port=None, - keys=None, password=None, agentEndpoint=None, - knownHosts=None, ui=None): - """ - Create and return a new endpoint which will try to create a new - connection to an SSH server and run a command over it. It will also - close the connection if there are problems leading up to the command - being executed, after the command finishes, or if the connection - L{Deferred} is cancelled. - - @param reactor: The reactor to use to establish the connection. - @type reactor: L{IReactorTCP} provider - - @param command: See L{__init__}'s C{command} argument. - - @param username: The username with which to authenticate to the SSH - server. - @type username: L{bytes} - - @param hostname: The hostname of the SSH server. - @type hostname: L{bytes} - - @param port: The port number of the SSH server. By default, the - standard SSH port number is used. - @type port: L{int} - - @param keys: Private keys with which to authenticate to the SSH server, - if key authentication is to be attempted (otherwise L{None}). - @type keys: L{list} of L{Key} - - @param password: The password with which to authenticate to the SSH - server, if password authentication is to be attempted (otherwise - L{None}). - @type password: L{bytes} or L{None} - - @param agentEndpoint: An L{IStreamClientEndpoint} provider which may be - used to connect to an SSH agent, if one is to be used to help with - authentication. - @type agentEndpoint: L{IStreamClientEndpoint} provider - - @param knownHosts: The currently known host keys, used to check the - host key presented by the server we actually connect to. - @type knownHosts: L{KnownHostsFile} - - @param ui: An object for interacting with users to make decisions about - whether to accept the server host keys. If L{None}, a L{ConsoleUI} - connected to /dev/tty will be used; if /dev/tty is unavailable, an - object which answers C{b"no"} to all prompts will be used. - @type ui: L{None} or L{ConsoleUI} - - @return: A new instance of C{cls} (probably - L{SSHCommandClientEndpoint}). - """ - helper = _NewConnectionHelper( - reactor, hostname, port, username, keys, password, - agentEndpoint, knownHosts, ui) - return cls(helper, command) - - @classmethod - def existingConnection(cls, connection, command): - """ - Create and return a new endpoint which will try to open a new channel on - an existing SSH connection and run a command over it. It will B{not} - close the connection if there is a problem executing the command or - after the command finishes. - - @param connection: An existing connection to an SSH server. - @type connection: L{SSHConnection} - - @param command: See L{SSHCommandClientEndpoint.newConnection}'s - C{command} parameter. - @type command: L{bytes} - - @return: A new instance of C{cls} (probably - L{SSHCommandClientEndpoint}). - """ - helper = _ExistingConnectionHelper(connection) - return cls(helper, command) - - def connect(self, protocolFactory): - """ - Set up an SSH connection, use a channel from that connection to launch - a command, and hook the stdin and stdout of that command up as a - transport for a protocol created by the given factory. - - @param protocolFactory: A L{Factory} to use to create the protocol - which will be connected to the stdin and stdout of the command on - the SSH server. - - @return: A L{Deferred} which will fire with an error if the connection - cannot be set up for any reason or with the protocol instance - created by C{protocolFactory} once it has been connected to the - command. - """ - d = self._creator.secureConnection() - d.addCallback(self._executeCommand, protocolFactory) - return d - - def _executeCommand(self, connection, protocolFactory): - """ - Given a secured SSH connection, try to execute a command in a new - channel created on it and associate the result with a protocol from the - given factory. - - @param connection: See L{SSHCommandClientEndpoint.existingConnection}'s - C{connection} parameter. - - @param protocolFactory: See L{SSHCommandClientEndpoint.connect}'s - C{protocolFactory} parameter. - - @return: See L{SSHCommandClientEndpoint.connect}'s return value. - """ - commandConnected = Deferred() - - def disconnectOnFailure(passthrough): - # Close the connection immediately in case of cancellation, since - # that implies user wants it gone immediately (e.g. a timeout): - immediate = passthrough.check(CancelledError) - self._creator.cleanupConnection(connection, immediate) - return passthrough - - commandConnected.addErrback(disconnectOnFailure) - - channel = _CommandChannel( - self._creator, self._command, protocolFactory, commandConnected) - connection.openChannel(channel) - return commandConnected - - -class _ReadFile(object): - """ - A weakly file-like object which can be used with L{KnownHostsFile} to - respond in the negative to all prompts for decisions. - """ - - def __init__(self, contents): - """ - @param contents: L{bytes} which will be returned from every C{readline} - call. - """ - self._contents = contents - - def write(self, data): - """ - No-op. - - @param data: ignored - """ - - def readline(self, count=-1): - """ - Always give back the byte string that this L{_ReadFile} was initialized - with. - - @param count: ignored - - @return: A fixed byte-string. - @rtype: L{bytes} - """ - return self._contents - - def close(self): - """ - No-op. - """ - - -@implementer(ICowrieSSHConnectionCreator) -class _NewConnectionHelper(object): - """ - L{_NewConnectionHelper} implements L{ICowrieSSHConnectionCreator} by - establishing a brand new SSH connection, securing it, and authenticating. - """ - _KNOWN_HOSTS = _KNOWN_HOSTS - port = 22 - - def __init__(self, reactor, hostname, port, username, keys, - password, agentEndpoint, knownHosts, ui, - tty=FilePath(b"/dev/tty")): - """ - @param tty: The path of the tty device to use in case C{ui} is L{None}. - @type tty: L{FilePath} - - @see: L{SSHCommandClientEndpoint.newConnection} - """ - self.reactor = reactor - self.hostname = hostname - if port is not None: - self.port = port - self.username = username - self.keys = keys - self.password = password - self.agentEndpoint = agentEndpoint - if knownHosts is None: - knownHosts = self._knownHosts() - self.knownHosts = knownHosts - - if ui is None: - ui = ConsoleUI(self._opener) - self.ui = ui - self.tty = tty - - def _opener(self): - """ - Open the tty if possible, otherwise give back a file-like object from - which C{b"no"} can be read. - - For use as the opener argument to L{ConsoleUI}. - """ - try: - return self.tty.open("rb+") - except Exception: - # Give back a file-like object from which can be read a byte string - # that KnownHostsFile recognizes as rejecting some option (b"no"). - return _ReadFile(b"no") - - @classmethod - def _knownHosts(cls): - """ - - @return: A L{KnownHostsFile} instance pointed at the user's personal - I{known hosts} file. - @type: L{KnownHostsFile} - """ - return KnownHostsFile.fromPath(FilePath(expanduser(cls._KNOWN_HOSTS))) - - def secureConnection(self): - """ - Create and return a new SSH connection which has been secured and on - which authentication has already happened. - - @return: A L{Deferred} which fires with the ready-to-use connection or - with a failure if something prevents the connection from being - setup, secured, or authenticated. - """ - protocol = _CommandTransport(self) - ready = protocol.connectionReady - - sshClient = TCP4ClientEndpoint( - self.reactor, nativeString(self.hostname), self.port) - - d = connectProtocol(sshClient, protocol) - d.addCallback(lambda ignored: ready) - return d - - def cleanupConnection(self, connection, immediate): - """ - Clean up the connection by closing it. The command running on the - endpoint has ended so the connection is no longer needed. - - @param connection: The L{SSHConnection} to close. - @type connection: L{SSHConnection} - - @param immediate: Whether to close connection immediately. - @type immediate: L{bool}. - """ - if immediate: - # We're assuming the underlying connection is an ITCPTransport, - # which is what the current implementation is restricted to: - connection.transport.transport.abortConnection() - else: - connection.transport.loseConnection() - - -@implementer(ICowrieSSHConnectionCreator) -class _ExistingConnectionHelper(object): - """ - L{_ExistingConnectionHelper} implements L{ICowrieSSHConnectionCreator} by - handing out an existing SSH connection which is supplied to its - initializer. - """ - - def __init__(self, connection): - """ - @param connection: See L{SSHCommandClientEndpoint.existingConnection}'s - C{connection} parameter. - """ - self.connection = connection - - def secureConnection(self): - """ - - @return: A L{Deferred} that fires synchronously with the - already-established connection object. - """ - return succeed(self.connection) - - def cleanupConnection(self, connection, immediate): - """ - Do not do any cleanup on the connection. Leave that responsibility to - whatever code created it in the first place. - - @param connection: The L{SSHConnection} which will not be modified in - any way. - @type connection: L{SSHConnection} - - @param immediate: An argument which will be ignored. - @type immediate: L{bool}. - """ diff --git a/src/cowrie/proxy/session.py b/src/cowrie/proxy/session.py deleted file mode 100644 index 82e98912..00000000 --- a/src/cowrie/proxy/session.py +++ /dev/null @@ -1,260 +0,0 @@ -# Copyright (c) 2017 Michel Oosterhof -# See the COPYRIGHT file for more information - -from __future__ import absolute_import, division - -from configparser import NoOptionError - -from twisted.conch.client.knownhosts import KnownHostsFile -from twisted.conch.ssh import common, keys, session -from twisted.conch.ssh.common import getNS -from twisted.internet import protocol, reactor -from twisted.python import log - -from cowrie.core.config import CowrieConfig -from cowrie.proxy import endpoints -from cowrie.ssh import channel - - -class _ProtocolFactory(): - """ - Factory to return the (existing) ssh session to pass to ssh command endpoint - It does not actually function as a factory - """ - - def __init__(self, protocol): - self.protocol = protocol - - def buildProtocol(self, addr): - return self.protocol - - -class ProxyClient(object): - """ - Dummy object because SSHSession expects a .client with an attached transport - - TODO: Forward ssh-exit-status - """ - transport = None - session = None - - def __init__(self, session): - self.session = session - self.transport = InBetween() - self.transport.client = self.session - - -class InBetween(protocol.Protocol): - """ - This is the glue between the SSH server one one side and the - SSH client on the other side - """ - - transport = None # Transport is the back-end the ssh-server - client = None # Client is the front-end, the ssh-client - buf = b"" # buffer to send to back-end - - def makeConnection(self, transport): - protocol.Protocol.makeConnection(self, transport) - - def connectionMade(self): - log.msg("IB: connection Made") - if len(self.buf) and self.transport is not None: - self.transport.write(self.buf) - self.buf = None - - def write(self, bytes): - """ - This is data going from the end-user to the back-end - """ - if not self.transport: - self.buf += bytes - return - log.msg("IB: write: {0} to transport {1}".format(repr(bytes), repr(self.transport))) - self.transport.write(bytes) - - def dataReceived(self, data): - """ - This is data going from the back-end to the end-user - """ - log.msg("IB: dataReceived: {0}".format(repr(data))) - self.client.write(data) - - def closed(self): - log.msg("IB: closed") - - def closeReceived(self): - log.msg("IB: closeRecieved") - - def loseConnection(self): - """ - Frontend disconnected - """ - log.msg("IB: loseConnection") - - def connectionLost(self, reason): - """ - Backend has disconnected - """ - log.msg("IB: ConnectionLost") - self.client.loseConnection() - - def eofReceived(self): - log.msg("IB: eofReceived") - - -class ProxySSHSession(channel.CowrieSSHChannel): - """ - For SSH sessions that are proxied to a back-end, this is the - SSHSession object that speaks to the client. It is responsible - for forwarding incoming requests to the backend. - """ - name = b'proxy-frontend-session' - buf = b"" - keys = [] - host = "" - port = 22 - user = "" - password = "" - knownHosts = None - - def __init__(self, *args, **kw): - channel.CowrieSSHChannel.__init__(self, *args, **kw) - - keyPath = CowrieConfig().get('proxy', 'private_key') - self.keys.append(keys.Key.fromFile(keyPath)) - - try: - keyPath = CowrieConfig().get('proxy', 'private_key') - self.keys.append(keys.Key.fromFile(keyPath)) - except NoOptionError: - self.keys = None - - knownHostsPath = CowrieConfig().get('proxy', 'known_hosts') - self.knownHosts = KnownHostsFile.fromPath(knownHostsPath) - - self.host = CowrieConfig().get('proxy', 'host') - self.port = CowrieConfig().getint('proxy', 'port') - self.user = CowrieConfig().get('proxy', 'user') - try: - self.password = CowrieConfig().get('proxy', 'password') - except NoOptionError: - self.password = None - - log.msg("knownHosts = {0}".format(repr(self.knownHosts))) - log.msg("host = {0}".format(self.host)) - log.msg("port = {0}".format(self.port)) - log.msg("user = {0}".format(self.user)) - - self.client = ProxyClient(self) - - def channelOpen(self, specificData): - """ - Once we open the frontend-session, also start connecting to back end - """ - channel.CowrieSSHChannel.channelOpen(self, specificData) - return - log.msg("channelOpen") - helper = endpoints._NewConnectionHelper(reactor, self.host, self.port, self.user, self.keys, - self.password, None, self.knownHosts, None) - log.msg("helper = {0}".format(repr(helper))) - d = helper.secureConnection() - d.addCallback(self._cbConnect) - d.addErrback(self._ebConnect) - log.msg("d = {0}".format(repr(d))) - return d - - def _ebConnect(self): - log.msg("ERROR CONNECTED TO BACKEND") - self._state = b'ERROR' - - def _cbConnect(self): - log.msg("CONNECTED TO BACKEND") - self._state = b'CONNECTED' - - def request_env(self, data): - name, rest = getNS(data) - value, rest = getNS(rest) - if rest: - raise ValueError("Bad data given in env request") - log.msg(eventid='cowrie.client.var', format='request_env: %(name)s=%(value)s', name=name, value=value) - # FIXME: This only works for shell, not for exec command - # if self.session: - # self.session.environ[name] = value - return 0 - - def request_pty_req(self, data): - term, windowSize, modes = session.parseRequest_pty_req(data) - log.msg('pty request: %r %r' % (term, windowSize)) - return 1 - - def request_window_change(self, data): - return 1 - - def request_subsystem(self, data): - subsystem, _ = common.getNS(data) - log.msg('asking for subsystem "{}"'.format(subsystem)) - return 0 - - def request_exec(self, data): - cmd, data = common.getNS(data) - log.msg('request_exec "{}"'.format(cmd)) - pf = _ProtocolFactory(self.client.transport) - endpoints.SSHCommandClientEndpoint.newConnection(reactor, cmd, self.user, self.host, - port=self.port, password=self.password).connect(pf) - return 1 - - def request_shell(self, data): - log.msg('request_shell') - pf = _ProtocolFactory(self.client.transport) - endpoints.SSHShellClientEndpoint.newConnection(reactor, self.user, self.host, - port=self.port, password=self.password).connect(pf) - return 1 - - def extReceived(self, dataType, data): - log.msg('weird extended data: {}'.format(dataType)) - - def request_agent(self, data): - log.msg('request_agent: {}'.format(repr(data), )) - return 0 - - def request_x11_req(self, data): - log.msg('request_x11: %s' % (repr(data),)) - return 0 - - def sendClose(self): - """ - Utility function to request to send close for this session - """ - self.conn.sendClose(self) - - def closed(self): - """ - This is reliably called on session close/disconnect and calls the avatar - """ - channel.CowrieSSHChannel.closed(self) - self.client = None - - def channelClosed(self): - log.msg("Called channelClosed in SSHSession") - - def closeReceived(self): - log.msg("closeReceived") - - def sendEOF(self): - """ - Utility function to request to send EOF for this session - """ - self.conn.sendEOF(self) - - def dataReceived(self, data): - if not self.client: - self.buf += data - return - self.client.transport.write(data) - - def eofReceived(self): - log.msg("RECEIVED EOF") - return - if self.client: - self.conn.sendClose(self) diff --git a/src/cowrie/ssh/factory.py b/src/cowrie/ssh/factory.py index 7c05cb50..e55694d7 100644 --- a/src/cowrie/ssh/factory.py +++ b/src/cowrie/ssh/factory.py @@ -18,8 +18,10 @@ from twisted.python import log from cowrie.core.config import CowrieConfig from cowrie.ssh import connection from cowrie.ssh import keys as cowriekeys -from cowrie.ssh import transport -from cowrie.ssh import userauth +from cowrie.ssh import transport as shellTransport +from cowrie.ssh.userauth import HoneyPotSSHUserAuthServer +from cowrie.ssh_proxy import server_transport as proxyTransport +from cowrie.ssh_proxy.userauth import ProxySSHAuthServer class CowrieSSHFactory(factory.SSHFactory): @@ -29,7 +31,8 @@ class CowrieSSHFactory(factory.SSHFactory): """ services = { - b'ssh-userauth': userauth.HoneyPotSSHUserAuthServer, + b'ssh-userauth': ProxySSHAuthServer if CowrieConfig().get('honeypot', 'backend') == 'proxy' + else HoneyPotSSHUserAuthServer, b'ssh-connection': connection.CowrieSSHConnection, } starttime = None @@ -72,6 +75,9 @@ class CowrieSSHFactory(factory.SSHFactory): except IOError: pass + # this can come from backend in the future, check HonSSH's slim client + self.ourVersionString = CowrieConfig().get('ssh', 'version', fallback='SSH-2.0-OpenSSH_6.0p1 Debian-4+deb7u2') + factory.SSHFactory.startFactory(self) log.msg("Ready to accept SSH connections") @@ -88,8 +94,10 @@ class CowrieSSHFactory(factory.SSHFactory): @rtype: L{cowrie.ssh.transport.HoneyPotSSHTransport} @return: The built transport. """ - - t = transport.HoneyPotSSHTransport() + if CowrieConfig().get('honeypot', 'backend', fallback='shell') == 'proxy': + t = proxyTransport.FrontendSSHTransport() + else: + t = shellTransport.HoneyPotSSHTransport() t.ourVersionString = self.ourVersionString t.supportedPublicKeys = list(self.privateKeys.keys()) diff --git a/src/cowrie/ssh/transport.py b/src/cowrie/ssh/transport.py index 316def28..7449019c 100644 --- a/src/cowrie/ssh/transport.py +++ b/src/cowrie/ssh/transport.py @@ -123,6 +123,9 @@ class HoneyPotSSHTransport(transport.SSHServerTransport, TimeoutMixin): self.dispatchMessage(messageNum, packet[1:]) packet = self.getPacket() + def dispatchMessage(self, message_num, payload): + transport.SSHServerTransport.dispatchMessage(self, message_num, payload) + def sendPacket(self, messageType, payload): """ Override because OpenSSH pads with 0 on KEXINIT diff --git a/src/cowrie/ssh_proxy/README.md b/src/cowrie/ssh_proxy/README.md new file mode 100644 index 00000000..2aaacc5c --- /dev/null +++ b/src/cowrie/ssh_proxy/README.md @@ -0,0 +1,44 @@ +The SSH proxy is divided into a server module (frontend) and a client module (backend). +When clients (the attackers) connect to Cowrie, we start a connection (using Twisted Conch's client) to a specified server. + +The proxy's structure is: + +``` + +------------------------------------------------------+ + | | + | | ++----------+ | +----------+ +-----------+ +---------+ | +------------+ +| | | | | | | | | | | | +| ATTACKER +<-->-<->+ FRONTEND +<---->+ HANDLER +<---->+ BACKEND +<->+<-->+ SSH SERVER | +| | | | | | | | | | | | ++----------+ | +----------+ +-----------+ +---------+ | +------------+ + | | + | COWRIE'S PROXY | + +------------------------------------------------------+ + + +``` + +Frontend is serverTransport.py, handler is ssh.py, and backend is clientTransport.py. + +When an attacker connects, authentication is performed between them and the frontend, and between backend and server. The frontend part is handled by Cowrie and Twisted's own service. The backend part is a simple password authentication specified in the config - we assume backends are in a secure network anyway. + +After authentication all SSH transport data is forwarded from attacker to server. Our proxy intercepts all messages and handles them as needed in ssh.py. We support exec, direct-tcpip and sftp, all of which have their own specification in the protocols directory. If a service is disabled in config, the handler does not forward the request to the server, instead creating and returning a default error message immediately. + + +## Authentication +Authentication leverages the same mechanism found in Cowrie's shell: it's set as a service provided by Twisted, and the same configurations apply when using both shell and proxy backends. + +## Managing the VM pool +The flow to get VMs from the pool might be a little complicated because of the amount of back-and-forth needed when using deferreds. + +The Pool interface is started in the Cowrie plugin (if enabled by configuration, of course), and the reference to it passed to both SSH and Telnet factories. + +When the pool handler is started by the plugin, it immediately establishes a connection to the pool server, setting values from configuration into that server. If the connection fails, or these values are not set correclty, Cowrie is aborted, since it wouldn't be possible to continue without a correctly configured pool. + +### SSH +When a new SSH connection is received in the frontend (serverTransport.py), the later has two tasks: perform userauth and connect to a backend. We start this in the connectionMade function. A pool connection is requested, and if that connection is successful we call pool_connection_success. + +We then request a backend via send_vm_request. If any of these operations fail, we disconnect the frontend. Else we have a function called when data is received from the pool, **received_pool_data**. + +When we receive a response to a VM request operation, we known we can start connecting to the backend, which we do. After that connection is established we can glue the two transports - frontend and backend - when the backendConnected variable is set to true in the backend. diff --git a/src/cowrie/proxy/__init__.py b/src/cowrie/ssh_proxy/__init__.py similarity index 100% rename from src/cowrie/proxy/__init__.py rename to src/cowrie/ssh_proxy/__init__.py diff --git a/src/cowrie/ssh_proxy/client_transport.py b/src/cowrie/ssh_proxy/client_transport.py new file mode 100644 index 00000000..f7747be4 --- /dev/null +++ b/src/cowrie/ssh_proxy/client_transport.py @@ -0,0 +1,166 @@ +# Copyright (c) 2019 Guilherme Borges +# All rights reserved. + +from twisted.conch.ssh import transport +from twisted.internet import defer, protocol +from twisted.protocols.policies import TimeoutMixin +from twisted.python import log + +from cowrie.core.config import CowrieConfig +from cowrie.ssh_proxy.util import bin_string_to_hex, string_to_hex + + +def get_int(data, length=4): + return int.from_bytes(data[:length], byteorder='big') + + +def get_bool(data): + return bool(get_int(data, length=1)) + + +def get_string(data): + length = get_int(data, 4) + value = data[4:length+4] + return length+4, value + + +class BackendSSHTransport(transport.SSHClientTransport, TimeoutMixin): + """ + This class represents the transport layer from Cowrie's proxy to the backend SSH server. It is responsible for + authentication to that server, and sending messages it gets to the handler. + """ + def __init__(self, factory): + self.delayedPackets = [] + self.factory = factory + self.canAuth = False + + # keep these from when frontend authenticates + self.frontendTriedUsername = None + self.frontendTriedPassword = None + + def connectionMade(self): + log.msg('Connected to SSH backend at {0}'.format(self.transport.getPeer().host)) + self.factory.server.client = self + self.factory.server.sshParse.set_client(self) + transport.SSHClientTransport.connectionMade(self) + + def verifyHostKey(self, pub_key, fingerprint): + return defer.succeed(True) + + def connectionSecure(self): + log.msg('Backend Connection Secured') + self.canAuth = True + self.authenticateBackend() + + def authenticateBackend(self, tried_username=None, tried_password=None): + """ + This is called when the frontend is authenticated, so as to give us the option to authenticate with the + username and password given by the attacker. + """ + + # we keep these here in case frontend has authenticated and backend hasn't established the secure channel yet; + # in that case, tried credentials are stored to be used whenever usearauth with backend can be performed + if tried_username and tried_password: + self.frontendTriedUsername = tried_username + self.frontendTriedPassword = tried_password + + # do nothing if frontend is not authenticated, or backend has not established a secure channel + if not self.factory.server.frontendAuthenticated or not self.canAuth: + return + + # we authenticate with the backend using the credentials provided + # TODO create the account in the backend before (contact the pool of VMs for example) + # so these credentials from the config may not be needed after all + username = CowrieConfig().get('proxy', 'backend_user').encode() + password = CowrieConfig().get('proxy', 'backend_pass').encode() + + log.msg('Will auth with backend: {0}/{1}'.format(username, password)) + self.sendPacket(5, bin_string_to_hex(b'ssh-userauth')) + payload = bin_string_to_hex(username) + \ + string_to_hex('ssh-connection') + \ + string_to_hex('password') + \ + b'\x00' + \ + bin_string_to_hex(password) + + self.sendPacket(50, payload) + self.factory.server.backendConnected = True + + # send packets from the frontend that were waiting to go to the backend + for packet in self.factory.server.delayedPackets: + self.factory.server.sshParse.parse_packet('[SERVER]', packet[0], packet[1]) + self.factory.server.delayedPackets = [] + + def connectionLost(self, reason): + if self.factory.server.pool_interface: + log.msg( + eventid='cowrie.proxy.client_disconnect', + format="Lost connection with the pool backend: id %(vm_id)s", + vm_id=self.factory.server.pool_interface.vm_id, + protocol='ssh' + ) + else: + log.msg( + eventid='cowrie.proxy.client_disconnect', + format="Lost connection with the proxy's backend: %(honey_ip)s:%(honey_port)s", + honey_ip=self.factory.server.backend_ip, + honey_port=self.factory.server.backend_port, + protocol='ssh' + ) + + self.transport.connectionLost(reason) + self.transport = None + + # if connection from frontend is not closed, do it here + if self.factory.server.transport: + self.factory.server.transport.loseConnection() + + def timeoutConnection(self): + """ + Make sure all sessions time out eventually. + Timeout is reset when authentication succeeds. + """ + log.msg('Timeout reached in BackendSSHTransport') + self.transport.loseConnection() + self.factory.server.transport.loseConnection() + + def dispatchMessage(self, message_num, payload): + if message_num in [6, 52]: + return # TODO consume these in connectionSecure + + if message_num == 98: + # looking for RFC 4254 - 6.10. Returning Exit Status + pointer = 4 # ignore recipient_channel + leng, message = get_string(payload[pointer:]) + + if message == b'exit-status': + pointer += leng + 1 # also boolean ignored + exit_status = get_int(payload[pointer:]) + log.msg('exitCode: {0}'.format(exit_status)) + + if transport.SSHClientTransport.isEncrypted(self, "both"): + self.packet_buffer(message_num, payload) + else: + transport.SSHClientTransport.dispatchMessage(self, message_num, payload) + + def packet_buffer(self, message_num, payload): + """ + We can only proceed if authentication has been performed between client and proxy. Meanwhile we hold packets + from the backend to the frontend in here. + """ + if not self.factory.server.frontendAuthenticated: + # wait till frontend connects and authenticates to send packets to them + log.msg('Connection to client not ready, buffering packet from backend') + self.delayedPackets.append([message_num, payload]) + else: + if len(self.delayedPackets) > 0: + self.delayedPackets.append([message_num, payload]) + for packet in self.delayedPackets: + self.factory.server.sshParse.parse_packet('[CLIENT]', packet[0], packet[1]) + self.delayedPackets = [] + else: + self.factory.server.sshParse.parse_packet('[CLIENT]', message_num, payload) + + +class BackendSSHFactory(protocol.ClientFactory): + def buildProtocol(self, addr): + return BackendSSHTransport(self) diff --git a/src/cowrie/ssh_proxy/protocols/__init__.py b/src/cowrie/ssh_proxy/protocols/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/cowrie/ssh_proxy/protocols/base_protocol.py b/src/cowrie/ssh_proxy/protocols/base_protocol.py new file mode 100644 index 00000000..b14a94d7 --- /dev/null +++ b/src/cowrie/ssh_proxy/protocols/base_protocol.py @@ -0,0 +1,84 @@ +# Copyright (c) 2016 Thomas Nicholson +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# 3. The names of the author(s) may not be used to endorse or promote +# products derived from this software without specific prior written +# permission. +# +# THIS SOFTWARE IS PROVIDED BY THE AUTHORS ``AS IS'' AND ANY EXPRESS OR +# IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES +# OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. +# IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY DIRECT, INDIRECT, +# INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, +# BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED +# AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY +# OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF +# SUCH DAMAGE. + + +class BaseProtocol(object): + data = '' + packetSize = 0 + name = '' + uuid = '' + ttylog_file = None + + def __init__(self, uuid=None, name=None, ssh=None): + if uuid is not None: + self.uuid = uuid + + if name is not None: + self.name = name + + if ssh is not None: + self.ssh = ssh + + def parse_packet(self, parent, data): + # log.msg(parent + ' ' + repr(data)) + # log.msg(parent + ' ' + '\'\\x' + "\\x".join("{:02x}".format(ord(c)) for c in self.data) + '\'') + pass + + def channel_closed(self): + pass + + def extract_int(self, length): + value = int.from_bytes(self.data[:length], byteorder='big') + self.packetSize = self.packetSize - length + self.data = self.data[length:] + return value + + def put_int(self, number): + return number.to_bytes(4, byteorder='big') + + def extract_string(self): + length = self.extract_int(4) + value = self.data[:length] + self.packetSize -= length + self.data = self.data[length:] + return value + + def extract_bool(self): + value = self.extract_int(1) + return bool(value) + + def extract_data(self): + length = self.extract_int(4) + self.packetSize = length + value = self.data + self.packetSize -= len(value) + self.data = '' + return value + + def __deepcopy__(self, memo): + return None diff --git a/src/cowrie/ssh_proxy/protocols/exec_term.py b/src/cowrie/ssh_proxy/protocols/exec_term.py new file mode 100644 index 00000000..dd497d4d --- /dev/null +++ b/src/cowrie/ssh_proxy/protocols/exec_term.py @@ -0,0 +1,87 @@ +# Copyright (c) 2016 Thomas Nicholson +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# 3. The names of the author(s) may not be used to endorse or promote +# products derived from this software without specific prior written +# permission. +# +# THIS SOFTWARE IS PROVIDED BY THE AUTHORS ``AS IS'' AND ANY EXPRESS OR +# IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES +# OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. +# IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY DIRECT, INDIRECT, +# INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, +# BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED +# AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY +# OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF +# SUCH DAMAGE. + +import os +import time + +from twisted.python import log + +from cowrie.core import ttylog +from cowrie.core.config import CowrieConfig +from cowrie.ssh_proxy.protocols import base_protocol + + +class ExecTerm(base_protocol.BaseProtocol): + def __init__(self, uuid, channelName, ssh, channelId, command): + super(ExecTerm, self).__init__(uuid, channelName, ssh) + + log.msg(eventid='cowrie.command.input', + input=command.decode('ascii'), + format='CMD: %(input)s') + + self.transportId = ssh.server.transportId + self.channelId = channelId + + self.startTime = time.time() + self.ttylogPath = CowrieConfig().get('honeypot', 'ttylog_path') + self.ttylogEnabled = CowrieConfig().getboolean('honeypot', 'ttylog', fallback=True) + self.ttylogSize = 0 + + if self.ttylogEnabled: + self.ttylogFile = '{0}/{1}-{2}-{3}e.log'.format( + self.ttylogPath, time.strftime('%Y%m%d-%H%M%S'), self.transportId, self.channelId) + ttylog.ttylog_open(self.ttylogFile, self.startTime) + + def parse_packet(self, parent, payload): + if self.ttylogEnabled: + ttylog.ttylog_write(self.ttylogFile, len(payload), ttylog.TYPE_OUTPUT, time.time(), payload) + self.ttylogSize += len(payload) + + def channel_closed(self): + if self.ttylogEnabled: + ttylog.ttylog_close(self.ttylogFile, time.time()) + shasum = ttylog.ttylog_inputhash(self.ttylogFile) + shasumfile = os.path.join(self.ttylogPath, shasum) + + if os.path.exists(shasumfile): + duplicate = True + os.remove(self.ttylogFile) + else: + duplicate = False + os.rename(self.ttylogFile, shasumfile) + umask = os.umask(0) + os.umask(umask) + os.chmod(shasumfile, 0o666 & ~umask) + + log.msg(eventid='cowrie.log.closed', + format='Closing TTY Log: %(ttylog)s after %(duration)d seconds', + ttylog=shasumfile, + size=self.ttylogSize, + shasum=shasum, + duplicate=duplicate, + duration=time.time() - self.startTime) diff --git a/src/cowrie/proxy/server.py b/src/cowrie/ssh_proxy/protocols/port_forward.py similarity index 66% rename from src/cowrie/proxy/server.py rename to src/cowrie/ssh_proxy/protocols/port_forward.py index 68d0c60e..adaa9fe8 100644 --- a/src/cowrie/proxy/server.py +++ b/src/cowrie/ssh_proxy/protocols/port_forward.py @@ -1,4 +1,4 @@ -# Copyright (c) 2015 Michel Oosterhof +# Copyright (c) 2016 Thomas Nicholson # All rights reserved. # # Redistribution and use in source and binary forms, with or without @@ -26,21 +26,15 @@ # OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF # SUCH DAMAGE. -from __future__ import absolute_import, division +# Inspiration and code snippets used from: +# http://www.codeproject.com/Tips/612847/Generate-a-quick-and-easy-custom-pcap-file-using-P -from cowrie.core.config import CowrieConfig +from cowrie.ssh_proxy.protocols import base_protocol -class CowrieServer(object): - """ - In traditional Kippo each connection gets its own simulated machine. - This is not always ideal, sometimes two connections come from the same - source IP address. we want to give them the same environment as well. - So files uploaded through SFTP are visible in the SSH session. - This class represents a 'virtual server' that can be shared between - multiple Cowrie connections - """ +class PortForward(base_protocol.BaseProtocol): + def __init__(self, uuid, chan_name, ssh): + super(PortForward, self).__init__(uuid, chan_name, ssh) - def __init__(self, realm): - self.avatars = [] - self.hostname = CowrieConfig().get('honeypot', 'hostname') + def parse_packet(self, parent, payload): + pass diff --git a/src/cowrie/ssh_proxy/protocols/sftp.py b/src/cowrie/ssh_proxy/protocols/sftp.py new file mode 100644 index 00000000..fb522501 --- /dev/null +++ b/src/cowrie/ssh_proxy/protocols/sftp.py @@ -0,0 +1,312 @@ +# Copyright (c) 2016 Thomas Nicholson +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# 3. The names of the author(s) may not be used to endorse or promote +# products derived from this software without specific prior written +# permission. +# +# THIS SOFTWARE IS PROVIDED BY THE AUTHORS ``AS IS'' AND ANY EXPRESS OR +# IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES +# OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. +# IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY DIRECT, INDIRECT, +# INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, +# BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED +# AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY +# OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF +# SUCH DAMAGE. + +from twisted.python import log + +from cowrie.ssh_proxy.protocols import base_protocol + + +class SFTP(base_protocol.BaseProtocol): + prevID = '' + ID = '' + handle = '' + path = '' + command = '' + payloadSize = 0 + payloadOffset = 0 + theFile = '' + + packetLayout = { + 1: 'SSH_FXP_INIT', + # ['uint32', 'version'], [['string', 'extension_name'], ['string', 'extension_data']]] + 2: 'SSH_FXP_VERSION', + # [['uint32', 'version'], [['string', 'extension_name'], ['string', 'extension_data']]] + 3: 'SSH_FXP_OPEN', + # [['uint32', 'id'], ['string', 'filename'], ['uint32', 'pflags'], ['ATTRS', 'attrs']] + 4: 'SSH_FXP_CLOSE', # [['uint32', 'id'], ['string', 'handle']] + 5: 'SSH_FXP_READ', # [['uint32', 'id'], ['string', 'handle'], ['uint64', 'offset'], ['uint32', 'len']] + 6: 'SSH_FXP_WRITE', + # [['uint32', 'id'], ['string', 'handle'], ['uint64', 'offset'], ['string', 'data']] + 7: 'SSH_FXP_LSTAT', # [['uint32', 'id'], ['string', 'path']] + 8: 'SSH_FXP_FSTAT', # [['uint32', 'id'], ['string', 'handle']] + 9: 'SSH_FXP_SETSTAT', # [['uint32', 'id'], ['string', 'path'], ['ATTRS', 'attrs']] + 10: 'SSH_FXP_FSETSTAT', # [['uint32', 'id'], ['string', 'handle'], ['ATTRS', 'attrs']] + 11: 'SSH_FXP_OPENDIR', # [['uint32', 'id'], ['string', 'path']] + 12: 'SSH_FXP_READDIR', # [['uint32', 'id'], ['string', 'handle']] + 13: 'SSH_FXP_REMOVE', # [['uint32', 'id'], ['string', 'filename']] + 14: 'SSH_FXP_MKDIR', # [['uint32', 'id'], ['string', 'path'], ['ATTRS', 'attrs']] + 15: 'SSH_FXP_RMDIR', # [['uint32', 'id'], ['string', 'path']] + 16: 'SSH_FXP_REALPATH', # [['uint32', 'id'], ['string', 'path']] + 17: 'SSH_FXP_STAT', # [['uint32', 'id'], ['string', 'path']] + 18: 'SSH_FXP_RENAME', # [['uint32', 'id'], ['string', 'oldpath'], ['string', 'newpath']] + 19: 'SSH_FXP_READLINK', # [['uint32', 'id'], ['string', 'path']] + 20: 'SSH_FXP_SYMLINK', # [['uint32', 'id'], ['string', 'linkpath'], ['string', 'targetpath']] + 101: 'SSH_FXP_STATUS', + # [['uint32', 'id'], ['uint32', 'error_code'], ['string', 'error_message'], ['string', 'language']] + 102: 'SSH_FXP_HANDLE', # [['uint32', 'id'], ['string', 'handle']] + 103: 'SSH_FXP_DATA', # [['uint32', 'id'], ['string', 'data']] + 104: 'SSH_FXP_NAME', + # [['uint32', 'id'], ['uint32', 'count'], [['string', 'filename'], ['string', 'longname'], ['ATTRS', 'attrs']]] + 105: 'SSH_FXP_ATTRS', # [['uint32', 'id'], ['ATTRS', 'attrs']] + 200: 'SSH_FXP_EXTENDED', # [] + 201: 'SSH_FXP_EXTENDED_REPLY' # [] + } + + def __init__(self, uuid, chan_name, ssh): + super(SFTP, self).__init__(uuid, chan_name, ssh) + + self.clientPacket = base_protocol.BaseProtocol() + self.serverPacket = base_protocol.BaseProtocol() + self.parent = None + self.parentPacket = None + self.offset = 0 + + def parse_packet(self, parent, payload): + self.parent = parent + + if parent == '[SERVER]': + self.parentPacket = self.serverPacket + elif parent == '[CLIENT]': + self.parentPacket = self.clientPacket + + if self.parentPacket.packetSize == 0: + self.parentPacket.packetSize = int(payload[:4].hex(), 16) - len(payload[4:]) + payload = payload[4:] + self.parentPacket.data = payload + payload = '' + + else: + if len(payload) > self.parentPacket.packetSize: + self.parentPacket.data = self.parentPacket.data + payload[:self.parentPacket.packetSize] + payload = payload[self.parentPacket.packetSize:] + self.parentPacket.packetSize = 0 + else: + self.parentPacket.packetSize -= len(payload) + self.parentPacket.data = self.parentPacket.data + payload + payload = '' + + if self.parentPacket.packetSize == 0: + self.handle_packet(parent) + + if len(payload) != 0: + self.parse_packet(parent, payload) + + def handle_packet(self, parent): + self.packetSize = self.parentPacket.packetSize + self.data = self.parentPacket.data + + sftp_num = self.extract_int(1) + packet = self.packetLayout[sftp_num] + + self.prevID = self.ID + self.ID = self.extract_int(4) + + if packet == 'SSH_FXP_OPENDIR': + self.path = self.extract_string() + + elif packet == 'SSH_FXP_REALPATH': + self.path = self.extract_string() + self.command = b'cd ' + self.path + log.msg(parent + '[SFTP] Entered Command: ' + self.command.decode()) + + elif packet == 'SSH_FXP_OPEN': + self.path = self.extract_string() + pflags = '{0:08b}'.format(self.extract_int(4)) + + if pflags[6] == '1': + self.command = b'put ' + self.path + self.theFile = '' + # self.out.download_started(self.uuid, self.path) + elif pflags[7] == '1': + self.command = b'get ' + self.path + else: + # Unknown PFlag + log.msg(parent + '[SFTP] New SFTP pflag detected: %s %s' % + (pflags, self.data)) + + log.msg(parent + '[SFTP] Entered Command: ' + self.command) + + elif packet == 'SSH_FXP_READ': + pass + + elif packet == 'SSH_FXP_WRITE': + if self.handle == self.extract_string(): + self.offset = self.extract_int(8) + self.theFile = self.theFile[:self.offset] + self.extract_data() + + elif packet == 'SSH_FXP_HANDLE': + if self.ID == self.prevID: + self.handle = self.extract_string() + + elif packet == 'SSH_FXP_READDIR': + if self.handle == self.extract_string(): + self.command = b'ls ' + self.path + + elif packet == 'SSH_FXP_SETSTAT': + self.path = self.extract_string() + self.command = self.extract_attrs() + ' ' + self.path + + elif packet == 'SSH_FXP_EXTENDED': + cmd = self.extract_string() + self.path = self.extract_string() + + if cmd == 'statvfs@openssh.com': + self.command = b'df ' + self.path + elif cmd == 'hardlink@openssh.com': + self.command = b'ln ' + self.path + b' ' + self.extract_string() + elif cmd == 'posix-rename@openssh.com': + self.command = b'mv ' + self.path + b' ' + self.extract_string() + else: + # UNKNOWN COMMAND + log.msg(parent + '[SFTP] New SFTP Extended Command detected: %s %s' % + (cmd, self.data)) + + elif packet == 'SSH_FXP_EXTENDED_REPLY': + log.msg(parent + '[SFTP] Entered Command: ' + self.command) + # self.out.command_entered(self.uuid, self.command) + + elif packet == 'SSH_FXP_CLOSE': + if self.handle == self.extract_string(): + if b'get' in self.command: + log.msg(parent + '[SFTP] Finished Downloading: ' + self.path) + elif b'put' in self.command: + log.msg(parent + '[SFTP] Finished Uploading: ' + self.path) + + # if self.out.cfg.getboolean(['download', 'passive']): + # # self.out.make_downloads_folder() + # outfile = self.out.downloadFolder + datetime.datetime.now().strftime("%Y%m%d_%H%M%S_%f")\ + # + "-" + self.path.split('/')[-1] + # f = open(outfile, 'wb') + # f.write(self.theFile) + # f.close() + # #self.out.file_downloaded((self.uuid, True, self.path, outfile, None)) + + elif packet == 'SSH_FXP_SYMLINK': + self.command = b'ln -s ' + self.extract_string() + ' ' + self.extract_string() + + elif packet == 'SSH_FXP_MKDIR': + self.command = b'mkdir ' + self.extract_string() + + elif packet == 'SSH_FXP_REMOVE': + self.command = b'rm ' + self.extract_string() + + elif packet == 'SSH_FXP_RMDIR': + self.command = b'rmdir ' + self.extract_string() + + elif packet == 'SSH_FXP_STATUS': + if self.ID == self.prevID: + code = self.extract_int(4) + if code in [0, 1]: + if b'get' not in self.command and b'put' not in self.command: + log.msg(parent + '[SFTP] Entered Command: ' + self.command.decode()) + else: + message = self.extract_string() + log.msg(parent + '[SFTP] Failed Command: ' + self.command.decode() + ' Reason: ' + message) + + def extract_attrs(self): + cmd = '' + flags = '{0:08b}'.format(self.extract_int(4)) + + if flags[5] == '1': + perms = '{0:09b}'.format(self.extract_int(4)) + # log.msg(log.LPURPLE, self.parent + '[SFTP]', 'PERMS:' + perms) + chmod = str(int(perms[:3], 2)) + str(int(perms[3:6], 2)) + str(int(perms[6:], 2)) + cmd = 'chmod ' + chmod + elif flags[6] == '1': + user = str(self.extract_int(4)) + group = str(self.extract_int(4)) + cmd = 'chown ' + user + ':' + group + else: + pass + # Unknown attribute + # log.msg(log.LRED, self.parent + '[SFTP]', + # 'New SFTP Attribute detected - Please raise a HonSSH issue on github with the details: %s %s' % + # (flags, self.data)) + return cmd + + +''' +CLIENT SERVER + + SSH_FXP_INIT --> + <-- SSH_FXP_VERSION + + SSH_FXP_OPEN --> + <-- SSH_FXP_HANDLE (or SSH_FXP_STATUS if fail) + + SSH_FXP_READ --> + <-- SSH_FXP_DATA (or SSH_FXP_STATUS if fail) + + SSH_FXP_WRITE --> + <-- SSH_FXP_STATUS + + SSH_FXP_REMOVE --> + <-- SSH_FXP_STATUS + + SSH_FXP_RENAME --> + <-- SSH_FXP_STATUS + + SSH_FXP_MKDIR --> + <-- SSH_FXP_STATUS + + SSH_FXP_RMDIR --> + <-- SSH_FXP_STATUS + + SSH_FXP_OPENDIR --> + <-- SSH_FXP_HANDLE (or SSH_FXP_STATUS if fail) + + SSH_FXP_READDIR --> + <-- SSH_FXP_NAME (or SSH_FXP_STATUS if fail) + + SSH_FXP_STAT --> //Follows symlinks + <-- SSH_FXP_ATTRS (or SSH_FXP_STATUS if fail) + + SSH_FXP_LSTAT --> //Does not follow symlinks + <-- SSH_FXP_ATTRS (or SSH_FXP_STATUS if fail) + + SSH_FXP_FSTAT --> //Works on an open file/handle not a file path like (L)STAT + <-- SSH_FXP_ATTRS (or SSH_FXP_STATUS if fail) + + SSH_FXP_SETSTAT --> //Sets file attributes on path + <-- SSH_FXP_STATUS + + SSH_FXP_FSETSTAT--> //Sets file attributes on a handle + <-- SSH_FXP_STATUS + + SSH_FXP_READLINK --> //Used to find the target of a symlink + <-- SSH_FXP_NAME (or SSH_FXP_STATUS if fail) + + SSH_FXP_SYMLINK --> //Used to create a symlink + <-- SSH_FXP_NAME (or SSH_FXP_STATUS if fail) + + SSH_FXP_REALPATH --> //Relative path + <-- SSH_FXP_NAME (or SSH_FXP_STATUS if fail) + + SSH_FXP_CLOSE --> //Closes handle not session + <-- SSH_FXP_STATUS +''' diff --git a/src/cowrie/ssh_proxy/protocols/ssh.py b/src/cowrie/ssh_proxy/protocols/ssh.py new file mode 100644 index 00000000..9c565add --- /dev/null +++ b/src/cowrie/ssh_proxy/protocols/ssh.py @@ -0,0 +1,362 @@ +# Copyright (c) 2016 Thomas Nicholson +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# 3. The names of the author(s) may not be used to endorse or promote +# products derived from this software without specific prior written +# permission. +# +# THIS SOFTWARE IS PROVIDED BY THE AUTHORS ``AS IS'' AND ANY EXPRESS OR +# IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES +# OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. +# IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY DIRECT, INDIRECT, +# INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, +# BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED +# AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY +# OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF +# SUCH DAMAGE. + +import uuid + +from twisted.python import log + +from cowrie.core.config import CowrieConfig +from cowrie.ssh_proxy.protocols import base_protocol, exec_term, port_forward, sftp, term +from cowrie.ssh_proxy.util import int_to_hex, string_to_hex + + +class SSH(base_protocol.BaseProtocol): + packetLayout = { + 1: 'SSH_MSG_DISCONNECT', # ['uint32', 'reason_code'], ['string', 'reason'], ['string', 'language_tag'] + 2: 'SSH_MSG_IGNORE', # ['string', 'data'] + 3: 'SSH_MSG_UNIMPLEMENTED', # ['uint32', 'seq_no'] + 4: 'SSH_MSG_DEBUG', # ['boolean', 'always_display'] + 5: 'SSH_MSG_SERVICE_REQUEST', # ['string', 'service_name'] + 6: 'SSH_MSG_SERVICE_ACCEPT', # ['string', 'service_name'] + 20: 'SSH_MSG_KEXINIT', # ['string', 'service_name'] + 21: 'SSH_MSG_NEWKEYS', + 50: 'SSH_MSG_USERAUTH_REQUEST', # ['string', 'username'], ['string', 'service_name'], ['string', 'method_name'] + 51: 'SSH_MSG_USERAUTH_FAILURE', # ['name-list', 'authentications'], ['boolean', 'partial_success'] + 52: 'SSH_MSG_USERAUTH_SUCCESS', # + 53: 'SSH_MSG_USERAUTH_BANNER', # ['string', 'message'], ['string', 'language_tag'] + 60: 'SSH_MSG_USERAUTH_INFO_REQUEST', # ['string', 'name'], ['string', 'instruction'], + # ['string', 'language_tag'], ['uint32', 'num-prompts'], + # ['string', 'prompt[x]'], ['boolean', 'echo[x]'] + 61: 'SSH_MSG_USERAUTH_INFO_RESPONSE', # ['uint32', 'num-responses'], ['string', 'response[x]'] + 80: 'SSH_MSG_GLOBAL_REQUEST', # ['string', 'request_name'], ['boolean', 'want_reply'] #tcpip-forward + 81: 'SSH_MSG_REQUEST_SUCCESS', + 82: 'SSH_MSG_REQUEST_FAILURE', + 90: 'SSH_MSG_CHANNEL_OPEN', # ['string', 'channel_type'], ['uint32', 'sender_channel'], + # ['uint32', 'initial_window_size'], ['uint32', 'maximum_packet_size'], + 91: 'SSH_MSG_CHANNEL_OPEN_CONFIRMATION', # ['uint32', 'recipient_channel'], ['uint32', 'sender_channel'], + # ['uint32', 'initial_window_size'], ['uint32', 'maximum_packet_size'] + 92: 'SSH_MSG_CHANNEL_OPEN_FAILURE', # ['uint32', 'recipient_channel'], ['uint32', 'reason_code'], + # ['string', 'reason'], ['string', 'language_tag'] + 93: 'SSH_MSG_CHANNEL_WINDOW_ADJUST', # ['uint32', 'recipient_channel'], ['uint32', 'additional_bytes'] + 94: 'SSH_MSG_CHANNEL_DATA', # ['uint32', 'recipient_channel'], ['string', 'data'] + 95: 'SSH_MSG_CHANNEL_EXTENDED_DATA', # ['uint32', 'recipient_channel'], + # ['uint32', 'data_type_code'], ['string', 'data'] + 96: 'SSH_MSG_CHANNEL_EOF', # ['uint32', 'recipient_channel'] + 97: 'SSH_MSG_CHANNEL_CLOSE', # ['uint32', 'recipient_channel'] + 98: 'SSH_MSG_CHANNEL_REQUEST', # ['uint32', 'recipient_channel'], ['string', 'request_type'], + # ['boolean', 'want_reply'] + 99: 'SSH_MSG_CHANNEL_SUCCESS', + 100: 'SSH_MSG_CHANNEL_FAILURE' + } + + def __init__(self, server): + super(SSH, self).__init__() + + self.channels = [] + self.username = '' + self.password = '' + self.auth_type = '' + + self.sendOn = False + self.expect_password = 0 + self.server = server + self.channels = [] + self.client = None + + def set_client(self, client): + self.client = client + + def parse_packet(self, parent, message_num, payload): + self.data = payload + self.packetSize = len(payload) + self.sendOn = True + + if message_num in self.packetLayout: + packet = self.packetLayout[message_num] + else: + packet = 'UNKNOWN_{0}'.format(message_num) + + if parent == '[SERVER]': + direction = 'PROXY -> BACKEND' + else: + direction = 'BACKEND -> PROXY' + + # log raw packets if user sets so + if CowrieConfig().getboolean('proxy', 'log_raw', fallback=False): + log.msg( + eventid='cowrie.proxy.ssh', + format="%(direction)s - %(packet)s - %(payload)s", + direction=direction, + packet=packet.ljust(37), + payload=repr(payload), + protocol='ssh' + ) + + if packet == 'SSH_MSG_SERVICE_REQUEST': + service = self.extract_string() + if service == b'ssh-userauth': + self.sendOn = False + + # - UserAuth + if packet == 'SSH_MSG_USERAUTH_REQUEST': + self.sendOn = False + self.username = self.extract_string() + self.extract_string() # service + self.auth_type = self.extract_string() + + if self.auth_type == b'password': + self.extract_bool() + self.password = self.extract_string() + # self.server.sendPacket(52, b'') + + elif self.auth_type == b'publickey': + self.sendOn = False + self.server.sendPacket(51, string_to_hex('password') + chr(0).encode()) + + elif packet == 'SSH_MSG_USERAUTH_FAILURE': + self.sendOn = False + auth_list = self.extract_string() + + if b'publickey' in auth_list: + log.msg('[SSH] Detected Public Key Auth - Disabling!') + payload = string_to_hex('password') + chr(0).encode() + + elif packet == 'SSH_MSG_USERAUTH_SUCCESS': + self.sendOn = False + if len(self.username) > 0 and len(self.password) > 0: + # self.server.login_successful(self.username, self.password) + pass + + elif packet == 'SSH_MSG_USERAUTH_INFO_REQUEST': + self.sendOn = False + self.auth_type = b'keyboard-interactive' + self.extract_string() + self.extract_string() + self.extract_string() + num_prompts = self.extract_int(4) + for i in range(0, num_prompts): + request = self.extract_string() + self.extract_bool() + + if 'password' in request.lower(): + self.expect_password = i + + elif packet == 'SSH_MSG_USERAUTH_INFO_RESPONSE': + self.sendOn = False + num_responses = self.extract_int(4) + for i in range(0, num_responses): + response = self.extract_string() + if i == self.expect_password: + self.password = response + + # - End UserAuth + # - Channels + elif packet == 'SSH_MSG_CHANNEL_OPEN': + channel_type = self.extract_string() + channel_id = self.extract_int(4) + + log.msg('got channel {} request'.format(channel_type)) + + if channel_type == b'session': + self.create_channel(parent, channel_id, channel_type) + elif channel_type == b'direct-tcpip' or channel_type == b'forwarded-tcpip': + self.extract_int(4) + self.extract_int(4) + + dst_ip = self.extract_string() + dst_port = self.extract_int(4) + + src_ip = self.extract_string() + src_port = self.extract_int(4) + + if CowrieConfig().getboolean('ssh', 'forwarding'): + log.msg(eventid='cowrie.direct-tcpip.request', + format='direct-tcp connection request to %(dst_ip)s:%(dst_port)s ' + 'from %(src_ip)s:%(src_port)s', + dst_ip=dst_ip, dst_port=dst_port, + src_ip=src_ip, src_port=src_port) + + the_uuid = uuid.uuid4().hex + self.create_channel(parent, channel_id, channel_type) + + if parent == '[SERVER]': + other_parent = '[CLIENT]' + the_name = '[LPRTF' + str(channel_id) + ']' + else: + other_parent = '[SERVER]' + the_name = '[RPRTF' + str(channel_id) + ']' + + channel = self.get_channel(channel_id, other_parent) + channel['name'] = the_name + channel['session'] = port_forward.PortForward(the_uuid, channel['name'], self) + else: + log.msg('[SSH] Detected Port Forwarding Channel - Disabling!') + log.msg(eventid='cowrie.direct-tcpip.data', + format='discarded direct-tcp forward request %(id)s to %(dst_ip)s:%(dst_port)s ', + dst_ip=dst_ip, dst_port=dst_port) + + self.sendOn = False + self.send_back(parent, 92, int_to_hex(channel_id) + int_to_hex(1) + + string_to_hex('open failed') + int_to_hex(0)) + else: + # UNKNOWN CHANNEL TYPE + if channel_type not in [b'exit-status']: + log.msg('[SSH Unknown Channel Type Detected - {0}'.format(channel_type)) + + elif packet == 'SSH_MSG_CHANNEL_OPEN_CONFIRMATION': + channel = self.get_channel(self.extract_int(4), parent) + # SENDER + sender_id = self.extract_int(4) + + if parent == '[SERVER]': + channel['serverID'] = sender_id + elif parent == '[CLIENT]': + channel['clientID'] = sender_id + # CHANNEL OPENED + + elif packet == 'SSH_MSG_CHANNEL_OPEN_FAILURE': + channel = self.get_channel(self.extract_int(4), parent) + self.channels.remove(channel) + # CHANNEL FAILED TO OPEN + + elif packet == 'SSH_MSG_CHANNEL_REQUEST': + channel = self.get_channel(self.extract_int(4), parent) + channel_type = self.extract_string() + the_uuid = uuid.uuid4().hex + + if channel_type == b'shell': + channel['name'] = '[TERM' + str(channel['serverID']) + ']' + channel['session'] = term.Term(the_uuid, channel['name'], self, channel['clientID']) + + elif channel_type == b'exec': + channel['name'] = '[EXEC' + str(channel['serverID']) + ']' + self.extract_bool() + command = self.extract_string() + channel['session'] = exec_term.ExecTerm(the_uuid, channel['name'], self, channel['serverID'], command) + + elif channel_type == b'subsystem': + self.extract_bool() + subsystem = self.extract_string() + + if subsystem == b'sftp': + if CowrieConfig().getboolean('ssh', 'sftp_enabled'): + channel['name'] = '[SFTP' + str(channel['serverID']) + ']' + # self.out.channel_opened(the_uuid, channel['name']) + channel['session'] = sftp.SFTP(the_uuid, channel['name'], self) + else: + # log.msg(log.LPURPLE, '[SSH]', 'Detected SFTP Channel Request - Disabling!') + self.sendOn = False + self.send_back(parent, 100, int_to_hex(channel['serverID'])) + else: + # UNKNOWN SUBSYSTEM + log.msg('[SSH] Unknown Subsystem Type Detected - ' + subsystem.decode()) + else: + # UNKNOWN CHANNEL REQUEST TYPE + if channel_type not in [b'window-change', b'env', b'pty-req', b'exit-status', b'exit-signal']: + log.msg('[SSH] Unknown Channel Request Type Detected - {0}'.format(channel_type.decode())) + + elif packet == 'SSH_MSG_CHANNEL_FAILURE': + pass + + elif packet == 'SSH_MSG_CHANNEL_CLOSE': + channel = self.get_channel(self.extract_int(4), parent) + # Is this needed?! + channel[parent] = True + + if '[SERVER]' in channel and '[CLIENT]' in channel: + # CHANNEL CLOSED + if channel['session'] is not None: + log.msg('remote close') + channel['session'].channel_closed() + + self.channels.remove(channel) + # - END Channels + # - ChannelData + elif packet == 'SSH_MSG_CHANNEL_DATA': + channel = self.get_channel(self.extract_int(4), parent) + channel['session'].parse_packet(parent, self.extract_string()) + + elif packet == 'SSH_MSG_CHANNEL_EXTENDED_DATA': + channel = self.get_channel(self.extract_int(4), parent) + self.extract_int(4) + channel['session'].parse_packet(parent, self.extract_string()) + # - END ChannelData + + elif packet == 'SSH_MSG_GLOBAL_REQUEST': + channel_type = self.extract_string() + if channel_type == b'tcpip-forward': + if not CowrieConfig().getboolean(['ssh', 'forwarding']): + self.sendOn = False + self.send_back(parent, 82, '') + + if self.sendOn: + if parent == '[SERVER]': + self.client.sendPacket(message_num, payload) + else: + self.server.sendPacket(message_num, payload) + + def send_back(self, parent, message_num, payload): + packet = self.packetLayout[message_num] + + if parent == '[SERVER]': + direction = 'PROXY -> FRONTEND' + else: + direction = 'PROXY -> BACKEND' + + log.msg( + eventid='cowrie.proxy.ssh', + format="%(direction)s - %(packet)s - %(payload)s", + direction=direction, + packet=packet.ljust(37), + payload=repr(payload), + protocol='ssh' + ) + + if parent == '[SERVER]': + self.server.sendPacket(message_num, payload) + elif parent == '[CLIENT]': + self.client.sendPacket(message_num, payload) + + def create_channel(self, parent, channel_id, channel_type, session=None): + if parent == '[SERVER]': + self.channels.append({'serverID': channel_id, 'type': channel_type, 'session': session}) + elif parent == '[CLIENT]': + self.channels.append({'clientID': channel_id, 'type': channel_type, 'session': session}) + + def get_channel(self, channel_num, parent): + the_channel = None + for channel in self.channels: + if parent == '[CLIENT]': + search = 'serverID' + else: + search = 'clientID' + + if channel[search] == channel_num: + the_channel = channel + break + return the_channel diff --git a/src/cowrie/ssh_proxy/protocols/term.py b/src/cowrie/ssh_proxy/protocols/term.py new file mode 100644 index 00000000..b8a823c3 --- /dev/null +++ b/src/cowrie/ssh_proxy/protocols/term.py @@ -0,0 +1,184 @@ +# Copyright (c) 2016 Thomas Nicholson +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# 3. The names of the author(s) may not be used to endorse or promote +# products derived from this software without specific prior written +# permission. +# +# THIS SOFTWARE IS PROVIDED BY THE AUTHORS ``AS IS'' AND ANY EXPRESS OR +# IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES +# OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. +# IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY DIRECT, INDIRECT, +# INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, +# BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED +# AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY +# OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF +# SUCH DAMAGE. + +import os +import time + +from twisted.python import log + +from cowrie.core import ttylog +from cowrie.core.config import CowrieConfig +from cowrie.ssh_proxy.protocols import base_protocol + + +class Term(base_protocol.BaseProtocol): + def __init__(self, uuid, chan_name, ssh, channelId): + super(Term, self).__init__(uuid, chan_name, ssh) + + self.command = b'' + self.pointer = 0 + self.tabPress = False + self.upArrow = False + + self.transportId = ssh.server.transportId + self.channelId = channelId + + self.startTime = time.time() + self.ttylogPath = CowrieConfig().get('honeypot', 'ttylog_path') + self.ttylogEnabled = CowrieConfig().getboolean('honeypot', 'ttylog', fallback=True) + self.ttylogSize = 0 + + if self.ttylogEnabled: + self.ttylogFile = \ + '{0}/{1}-{2}-{3}i.log'.format(self.ttylogPath, time.strftime('%Y%m%d-%H%M%S'), uuid, self.channelId) + ttylog.ttylog_open(self.ttylogFile, self.startTime) + + def channel_closed(self): + if self.ttylogEnabled: + ttylog.ttylog_close(self.ttylogFile, time.time()) + shasum = ttylog.ttylog_inputhash(self.ttylogFile) + shasumfile = os.path.join(self.ttylogPath, shasum) + + if os.path.exists(shasumfile): + duplicate = True + os.remove(self.ttylogFile) + else: + duplicate = False + os.rename(self.ttylogFile, shasumfile) + umask = os.umask(0) + os.umask(umask) + os.chmod(shasumfile, 0o666 & ~umask) + + log.msg(eventid='cowrie.log.closed', + format='Closing TTY Log: %(ttylog)s after %(duration)d seconds', + ttylog=shasumfile, + size=self.ttylogSize, + shasum=shasum, + duplicate=duplicate, + duration=time.time() - self.startTime) + + def parse_packet(self, parent, payload): + self.data = payload + + if parent == '[SERVER]': + while len(self.data) > 0: + # If Tab Pressed + if self.data[:1] == b'\x09': + self.tabPress = True + self.data = self.data[1:] + # If Backspace Pressed + elif self.data[:1] == b'\x7f' or self.data[:1] == b'\x08': + if self.pointer > 0: + self.command = self.command[:self.pointer - 1] + self.command[self.pointer:] + self.pointer -= 1 + self.data = self.data[1:] + # If enter or ctrl+c or newline + elif self.data[:1] == b'\x0d' or self.data[:1] == b'\x03' or self.data[:1] == b'\x0a': + if self.data[:1] == b'\x03': + self.command += b'^C' + + self.data = self.data[1:] + if self.command != b'': + log.msg(eventid='cowrie.command.input', + input=self.command.decode('ascii'), + format='CMD: %(input)s') + + self.command = b'' + self.pointer = 0 + # If Home Pressed + elif self.data[:3] == b'\x1b\x4f\x48': + self.pointer = 0 + self.data = self.data[3:] + # If End Pressed + elif self.data[:3] == b'\x1b\x4f\x46': + self.pointer = len(self.command) + self.data = self.data[3:] + # If Right Pressed + elif self.data[:3] == b'\x1b\x5b\x43': + if self.pointer != len(self.command): + self.pointer += 1 + self.data = self.data[3:] + # If Left Pressed + elif self.data[:3] == b'\x1b\x5b\x44': + if self.pointer != 0: + self.pointer -= 1 + self.data = self.data[3:] + # If up or down arrow + elif self.data[:3] == b'\x1b\x5b\x41' or self.data[:3] == b'\x1b\x5b\x42': + self.upArrow = True + self.data = self.data[3:] + else: + self.command = self.command[:self.pointer] + self.data[:1] + self.command[self.pointer:] + self.pointer += 1 + self.data = self.data[1:] + + if self.ttylogEnabled: + self.ttylogSize += len(payload) + ttylog.ttylog_write(self.ttylogFile, len(payload), ttylog.TYPE_OUTPUT, time.time(), payload) + + elif parent == '[CLIENT]': + if self.tabPress: + if not self.data.startswith(b'\x0d'): + if self.data != b'\x07': + self.command = self.command + self.data + self.tabPress = False + + if self.upArrow: + while len(self.data) != 0: + # Backspace + if self.data[:1] == b'\x08': + self.command = self.command[:-1] + self.pointer -= 1 + self.data = self.data[1:] + # ESC[K - Clear Line + elif self.data[:3] == b'\x1b\x5b\x4b': + self.command = self.command[:self.pointer] + self.data = self.data[3:] + elif self.data[:1] == b'\x0d': + self.pointer = 0 + self.data = self.data[1:] + # Right Arrow + elif self.data[:3] == b'\x1b\x5b\x43': + self.pointer += 1 + self.data = self.data[3:] + elif self.data[:2] == b'\x1b\x5b' and self.data[3] == b'\x50': + self.data = self.data[4:] + # Needed?! + elif self.data[:1] != b'\x07' and self.data[:1] != b'\x0d': + self.command = self.command[:self.pointer] + self.data[:1] + self.command[self.pointer:] + self.pointer += 1 + self.data = self.data[1:] + else: + self.pointer += 1 + self.data = self.data[1:] + + self.upArrow = False + + if self.ttylogEnabled: + self.ttylogSize += len(payload) + ttylog.ttylog_write(self.ttylogFile, len(payload), ttylog.TYPE_INPUT, time.time(), payload) diff --git a/src/cowrie/ssh_proxy/server_transport.py b/src/cowrie/ssh_proxy/server_transport.py new file mode 100644 index 00000000..c2b35f52 --- /dev/null +++ b/src/cowrie/ssh_proxy/server_transport.py @@ -0,0 +1,397 @@ +# Copyright (c) 2016 Thomas Nicholson , 2019 Guilherme Borges +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# 3. The names of the author(s) may not be used to endorse or promote +# products derived from this software without specific prior written +# permission. +# +# THIS SOFTWARE IS PROVIDED BY THE AUTHORS ``AS IS'' AND ANY EXPRESS OR +# IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES +# OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. +# IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY DIRECT, INDIRECT, +# INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, +# BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED +# AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY +# OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF +# SUCH DAMAGE. + +import re +import struct +import time +import uuid +import zlib +from hashlib import md5 + +from twisted.conch.ssh import transport +from twisted.conch.ssh.common import getNS +from twisted.internet import reactor +from twisted.internet.endpoints import TCP4ClientEndpoint +from twisted.protocols.policies import TimeoutMixin +from twisted.python import log, randbytes + +from cowrie.core.config import CowrieConfig +from cowrie.ssh_proxy import client_transport +from cowrie.ssh_proxy.protocols import ssh + + +class FrontendSSHTransport(transport.SSHServerTransport, TimeoutMixin): + """ + Represents a connection from the frontend (a client or attacker). + When such connection is received, start the connection to the backend (the VM that will provide the session); + at the same time, perform the userauth service via ProxySSHAuthServer (built-in Cowrie's mechanism). + After both sides are authenticated, forward all things from one side to another. + """ + + # TODO merge this with HoneyPotSSHTransport(transport.SSHServerTransport, TimeoutMixin) + # maybe create a parent class with common methods for the two + def __init__(self): + self.timeoutCount = 0 + + self.sshParse = None + self.disconnected = False # what was this used for + + self.peer_ip = None + self.peer_port = 0 + self.local_ip = None + self.local_port = 0 + + self.startTime = None + self.transportId = None + + self.pool_interface = None + self.backendConnected = False + self.frontendAuthenticated = False + self.delayedPackets = [] + + # only used when simple proxy (no pool) set + self.backend_ip = None + self.backend_port = None + + def connectionMade(self): + """ + Called when the connection is made to the other side. We sent our + version and the MSG_KEXINIT packet. + """ + self.sshParse = ssh.SSH(self) + self.transportId = uuid.uuid4().hex[:12] + + self.peer_ip = self.transport.getPeer().host + self.peer_port = self.transport.getPeer().port + 1 + self.local_ip = self.transport.getHost().host + self.local_port = self.transport.getHost().port + + self.transport.write('{0}\r\n'.format(self.ourVersionString).encode()) + self.currentEncryptions = transport.SSHCiphers(b'none', b'none', b'none', b'none') + self.currentEncryptions.setKeys(b'', b'', b'', b'', b'', b'') + self.otherVersionString = 'Unknown' + + log.msg( + eventid='cowrie.session.connect', + format="New connection: %(src_ip)s:%(src_port)s (%(dst_ip)s:%(dst_port)s) [session: %(session)s]", + src_ip=self.peer_ip, + src_port=self.transport.getPeer().port, + dst_ip=self.local_ip, + dst_port=self.transport.getHost().port, + session=self.transportId, + sessionno='S{0}'.format(self.transport.sessionno), + protocol='ssh' + ) + + # if we have a pool connect to it and later request a backend, else just connect to a simple backend + # when pool is set we can just test self.pool_interface to the same effect of getting the config + if CowrieConfig().getboolean('proxy', 'enable_pool', fallback=False): + # request a backend + d = self.factory.pool_handler.request_interface() + d.addCallback(self.pool_connection_success) + d.addErrback(self.pool_connection_error) + else: + # simply a proxy, no pool + backend_ip = CowrieConfig().get('proxy', 'backend_ip') + backend_port = CowrieConfig().getint('proxy', 'backend_ssh_port') + self.connect_to_backend(backend_ip, backend_port) + + def pool_connection_error(self, reason): + log.msg('Conenction to backend pool refused: {0}. Disconnecting frontend...'.format(reason.value)) + self.transport.loseConnection() + + def pool_connection_success(self, pool_interface): + log.msg("Connected to backend pool") + + self.pool_interface = pool_interface + self.pool_interface.set_parent(self) + + # now request a backend + self.pool_interface.send_vm_request(self.peer_ip) + + def received_pool_data(self, operation, status, *data): + if operation == b'r': + log.msg('Got backend data from pool') + + honey_ip = data[0] + ssh_port = data[1] + self.connect_to_backend(honey_ip, ssh_port) + + def backend_connection_error(self, reason): + log.msg('Conenction to honeypot backend refused: {0}. Disconnecting frontend...'.format(reason.value)) + self.transport.loseConnection() + + def backend_connection_success(self, backendTransport): + log.msg("Connected to honeypot backend") + + self.startTime = time.time() + self.setTimeout(CowrieConfig().getint('honeypot', 'authentication_timeout', fallback=120)) + + def connect_to_backend(self, ip, port): + # connection to the backend starts here + client_factory = client_transport.BackendSSHFactory() + client_factory.server = self + + point = TCP4ClientEndpoint(reactor, ip, port, timeout=10) + d = point.connect(client_factory) + d.addCallback(self.backend_connection_success) + d.addErrback(self.backend_connection_error) + + def sendKexInit(self): + """ + Don't send key exchange prematurely + """ + if not self.gotVersion: + return + transport.SSHServerTransport.sendKexInit(self) + + def _unsupportedVersionReceived(self, remoteVersion): + """ + Change message to be like OpenSSH + """ + self.transport.write(b'Protocol major versions differ.\n') + self.transport.loseConnection() + + def dataReceived(self, data): + """ + First, check for the version string (SSH-2.0-*). After that has been + received, this method adds data to the buffer, and pulls out any + packets. + + @type data: C{str} + """ + self.buf += data + + # get version from start of communication; check if valid and supported by Twisted + if not self.gotVersion: + if b'\n' not in self.buf: + return + self.otherVersionString = self.buf.split(b'\n')[0].strip() + log.msg(eventid='cowrie.client.version', version=repr(self.otherVersionString), + format="Remote SSH version: %(version)s") + m = re.match(br'SSH-(\d+.\d+)-(.*)', self.otherVersionString) + if m is None: + log.msg("Bad protocol version identification: {}".format(repr(self.otherVersionString))) + self.transport.write(b'Protocol mismatch.\n') + self.transport.loseConnection() + return + else: + self.gotVersion = True + remote_version = m.group(1) + if remote_version not in self.supportedVersions: + self._unsupportedVersionReceived(None) + return + i = self.buf.index(b'\n') + self.buf = self.buf[i + 1:] + self.sendKexInit() + packet = self.getPacket() + while packet: + message_num = ord(packet[0:1]) + self.dispatchMessage(message_num, packet[1:]) + packet = self.getPacket() + + def dispatchMessage(self, message_num, payload): + # overriden dispatchMessage sets services, we do that here too then + # we're particularly interested in userauth, since Twisted does most of that for us + if message_num == 5: + self.ssh_SERVICE_REQUEST(payload) + elif 50 <= message_num <= 79: # userauth numbers + self.frontendAuthenticated = False + transport.SSHServerTransport.dispatchMessage(self, message_num, payload) # let userauth deal with it + + # TODO delay userauth until backend is connected? + + elif transport.SSHServerTransport.isEncrypted(self, "both"): + self.packet_buffer(message_num, payload) + else: + transport.SSHServerTransport.dispatchMessage(self, message_num, payload) + + def sendPacket(self, messageType, payload): + """ + Override because OpenSSH pads with 0 on KEXINIT + """ + if self._keyExchangeState != self._KEY_EXCHANGE_NONE: + if not self._allowedKeyExchangeMessageType(messageType): + self._blockedByKeyExchange.append((messageType, payload)) + return + + payload = chr(messageType).encode() + payload + if self.outgoingCompression: + payload = (self.outgoingCompression.compress(payload) + + self.outgoingCompression.flush(2)) + bs = self.currentEncryptions.encBlockSize + # 4 for the packet length and 1 for the padding length + totalSize = 5 + len(payload) + lenPad = bs - (totalSize % bs) + if lenPad < 4: + lenPad = lenPad + bs + if messageType == transport.MSG_KEXINIT: + padding = b'\0' * lenPad + else: + padding = randbytes.secureRandom(lenPad) + + packet = (struct.pack(b'!LB', + totalSize + lenPad - 4, lenPad) + + payload + padding) + encPacket = (self.currentEncryptions.encrypt(packet) + + self.currentEncryptions.makeMAC( + self.outgoingPacketSequence, packet)) + self.transport.write(encPacket) + self.outgoingPacketSequence += 1 + + def ssh_KEXINIT(self, packet): + k = getNS(packet[16:], 10) + strings, _ = k[:-1], k[-1] + (kexAlgs, keyAlgs, encCS, _, macCS, _, compCS, _, langCS, + _) = [s.split(b',') for s in strings] + + # hassh SSH client fingerprint + # https://github.com/salesforce/hassh + ckexAlgs = ','.join([alg.decode('utf-8') for alg in kexAlgs]) + cencCS = ','.join([alg.decode('utf-8') for alg in encCS]) + cmacCS = ','.join([alg.decode('utf-8') for alg in macCS]) + ccompCS = ','.join([alg.decode('utf-8') for alg in compCS]) + hasshAlgorithms = "{kex};{enc};{mac};{cmp}".format( + kex=ckexAlgs, + enc=cencCS, + mac=cmacCS, + cmp=ccompCS) + hassh = md5(hasshAlgorithms.encode('utf-8')).hexdigest() + + log.msg(eventid='cowrie.client.kex', + format="SSH client hassh fingerprint: %(hassh)s", + hassh=hassh, + hasshAlgorithms=hasshAlgorithms, + kexAlgs=kexAlgs, keyAlgs=keyAlgs, encCS=encCS, macCS=macCS, + compCS=compCS, langCS=langCS) + + return transport.SSHServerTransport.ssh_KEXINIT(self, packet) + + def timeoutConnection(self): + """ + Make sure all sessions time out eventually. + Timeout is reset when authentication succeeds. + """ + log.msg('Timeout reached in FrontendSSHTransport') + self.transport.loseConnection() + self.sshParse.client.transport.loseConnection() + + def setService(self, service): + """ + Remove login grace timeout, set zlib compression after auth + """ + # Reset timeout. Not everyone opens shell so need timeout at transport level + if service.name == b'ssh-connection': + self.setTimeout(CowrieConfig().getint('honeypot', 'interactive_timeout', fallback=300)) + + # when auth is successful we enable compression + # this is called right after MSG_USERAUTH_SUCCESS + if service.name == 'ssh-connection': + if self.outgoingCompressionType == 'zlib@openssh.com': + self.outgoingCompression = zlib.compressobj(6) + if self.incomingCompressionType == 'zlib@openssh.com': + self.incomingCompression = zlib.decompressobj() + + transport.SSHServerTransport.setService(self, service) + + def connectionLost(self, reason): + """ + This seems to be the only reliable place of catching lost connection + """ + self.setTimeout(None) + + try: + self.client.loseConnection() + except Exception: + pass + transport.SSHServerTransport.connectionLost(self, reason) + + self.transport.connectionLost(reason) + self.transport = None + + # if connection from backend is not closed, do it here + if self.sshParse.client and self.sshParse.client.transport: + self.sshParse.client.transport.loseConnection() + + # free connection + if self.pool_interface: + self.pool_interface.send_vm_free() + + if self.startTime is not None: # startTime is not set when auth fails + duration = time.time() - self.startTime + log.msg(eventid='cowrie.session.closed', + format='Connection lost after %(duration)d seconds', + duration=duration) + + def sendDisconnect(self, reason, desc): + """ + http://kbyte.snowpenguin.org/portal/2013/04/30/kippo-protocol-mismatch-workaround/ + Workaround for the "bad packet length" error message. + + @param reason: the reason for the disconnect. Should be one of the + DISCONNECT_* values. + @type reason: C{int} + @param desc: a description of the reason for the disconnection. + @type desc: C{str} + """ + if b'bad packet length' not in desc: + # With python >= 3 we can use super? + transport.SSHServerTransport.sendDisconnect(self, reason, desc) + else: + self.transport.write(b'Packet corrupt\n') + log.msg('Disconnecting with error, code {0}\nreason: {1}'.format(reason, desc)) + self.transport.loseConnection() + + def receiveError(self, reasonCode, description): + """ + Called when we receive a disconnect error message from the other + side. + + @param reasonCode: the reason for the disconnect, one of the + DISCONNECT_ values. + @type reasonCode: L{int} + @param description: a human-readable description of the + disconnection. + @type description: L{str} + """ + log.msg('Got remote error, code {0} reason: {1}'.format(reasonCode, description)) + + def packet_buffer(self, message_num, payload): + """ + We have to wait until we have a connection to the backend is ready. Meanwhile, we hold packets from client + to server in here. + """ + if not self.backendConnected: + # wait till backend connects to send packets to them + log.msg('Connection to backend not ready, buffering packet from frontend') + self.delayedPackets.append([message_num, payload]) + else: + if len(self.delayedPackets) > 0: + self.delayedPackets.append([message_num, payload]) + else: + self.sshParse.parse_packet('[SERVER]', message_num, payload) diff --git a/src/cowrie/ssh_proxy/userauth.py b/src/cowrie/ssh_proxy/userauth.py new file mode 100644 index 00000000..8a36d678 --- /dev/null +++ b/src/cowrie/ssh_proxy/userauth.py @@ -0,0 +1,34 @@ +# Copyright (c) 2009-2014 Upi Tamminen +# See the COPYRIGHT file for more information + +from __future__ import absolute_import, division + +from twisted.conch.ssh.common import getNS + +from cowrie.ssh import userauth + + +class ProxySSHAuthServer(userauth.HoneyPotSSHUserAuthServer): + def __init__(self): + super().__init__() + self.triedPassword = None + + def auth_password(self, packet): + """ + Overridden to get password + """ + self.triedPassword = getNS(packet[1:])[0] + + return super().auth_password(packet) + + def _cbFinishedAuth(self, result): + """ + We only want to return a success to the user, no service needs to be set. + Those will be proxied back to the backend. + """ + self.transport.sendPacket(52, b'') + self.transport.frontendAuthenticated = True + + # TODO store this somewhere else, and do not call from here + if self.transport.sshParse.client: + self.transport.sshParse.client.authenticateBackend(self.user, self.triedPassword) diff --git a/src/cowrie/ssh_proxy/util.py b/src/cowrie/ssh_proxy/util.py new file mode 100644 index 00000000..a26bbb83 --- /dev/null +++ b/src/cowrie/ssh_proxy/util.py @@ -0,0 +1,16 @@ +import struct + + +def string_to_hex(message): + b = message.encode('utf-8') + size = struct.pack('>L', len(b)) + return size + b + + +def bin_string_to_hex(message): + size = struct.pack('>L', len(message)) + return size + message + + +def int_to_hex(value): + return struct.pack('>L', value)