Search code examples
pythonparamiko

Paramiko: nest ssh session to another machine while preserving paramiko functionality (ProxyJump)


I'm trying to use paramiko to bounce an SSH session via netcat:

 MyLocalMachine ----||----> MiddleMachine --(netcat)--> AnotherMachine
 ('localhost')  (firewall)   ('1.1.1.1')                 ('2.2.2.2')
  • There is no direct connection from MyLocalMachine to AnotherMachine
  • The SSH server on MiddleMachine will not accept any attempts to open a direct-tcpip channel connected to AnotherMachine
  • I can't use SSH keys. I can only connect via given username and password.
  • I can't use sshpass
  • I can't use PExpect
  • I want to connect automatically
  • I want to preserve all of paramiko functionality

I can achieve this partially using the following code:

cli = paramiko.SSHClient()
cli.set_missing_host_key_policy(paramiko.AutoAddPolicy())
proxy = paramiko.ProxyCommand('ssh user@1.1.1.1 nc 2.2.2.2 22')
cli.connect(hostname='2.2.2.2', username='user', password='pass', sock=proxy)

The thing is, that because ProxyCommand is using subprocess.Popen to run the given command, it is asking me to give the password "ad-hoc", from user input (also, it requires the OS on MyLocalMachine to have ssh installed - which isn't always the case).

Since ProxyCommand's methods (recv, send) are a simple bindings to apropriate POpen methods, I was wondering if it would be possible to trick paramiko client into using another client's session as the proxy?


Solution

  • TL;DR: I managed to do it using simple exec_command call and a class that pretends to be a sock.

    To summarize:

    • This solution does not use any other port than 22. If you can manually connect to the machine by nesting ssh clients - it will work. It doesn't require any port forwarding nor configuration changes.
    • It works without prompting for password (everything is automatic)
    • It nests ssh sessions while preserving paramiko functionality.
    • You can nest sessions as many times as you want
    • It requires netcat (nc) installed on the proxy host - although anything that can provide basic netcat functionality (moving data between a socket and stdin/stdout) will work.

    So, here be the solution:

    The masquerader

    The following code defines a class that can be used in place of paramiko.ProxyCommand. It supplies all the methods that a standard socket object does. The init method of this class takes the 3-tupple that exec_command() normally returns:

    Note: It was tested extensively by me, but you shouldn't take anything for granted. It is a hack.

    import paramiko
    import time
    import socket     
    from select import select                                                       
                                                                               
                                                                               
    class ParaProxy(paramiko.proxy.ProxyCommand):                      
        def __init__(self, stdin, stdout, stderr):                             
            self.stdin = stdin                                                 
            self.stdout = stdout                                               
            self.stderr = stderr
            self.timeout = None
            self.channel = stdin.channel                                               
                                                                               
        def send(self, content):                                               
            try:                                                               
                self.stdin.write(content)                                      
            except IOError as exc:                                             
                raise socket.error("Error: {}".format(exc))                                                    
            return len(content)                                                
                                                                               
        def recv(self, size):                                                  
            try:
                buffer = b''
                start = time.time()
    
                while len(buffer) < size:
                    select_timeout = self._calculate_remaining_time(start)
                    ready, _, _ = select([self.stdout.channel], [], [],
                                         select_timeout)
                    if ready and self.stdout.channel is ready[0]:
                          buffer += self.stdout.read(size - len(buffer))
    
            except socket.timeout:
                if not buffer:
                    raise
    
            except IOError as e:
                return ""
    
            return buffer
    
        def _calculate_remaining_time(self, start):
            if self.timeout is not None:
                elapsed = time.time() - start
                if elapsed >= self.timeout:
                    raise socket.timeout()
                return self.timeout - elapsed
            return None                                   
                                                                               
        def close(self):                                                       
            self.stdin.close()                                                 
            self.stdout.close()                                                
            self.stderr.close()
            self.channel.close()                                                                                                                            
    

    The usage

    The following shows how I used the above class to solve my problem:

    # Connecting to MiddleMachine and executing netcat
    mid_cli = paramiko.SSHClient()
    mid_cli.set_missing_host_key_policy(paramiko.AutoAddPolicy())
    mid_cli.connect(hostname='1.1.1.1', username='user', password='pass')
    io_tupple = mid_cli.exec_command('nc 2.2.2.2 22')
    
    # Instantiate the 'masquerader' class
    proxy = ParaProxy(*io_tupple)
    
    # Connecting to AnotherMachine and executing... anything...
    end_cli = paramiko.SSHClient()
    end_cli.set_missing_host_key_policy(paramiko.AutoAddPolicy())
    end_cli.connect(hostname='2.2.2.2', username='user', password='pass', sock=proxy)
    end_cli.exec_command('echo THANK GOD FINALLY')
    

    Et voila.