0x00 前言 为了加深对SSH协议的理解,准备自己实现一个SSH服务端,需要同时支持Windows、Linux、MacOS三大系统。为了尽量提升性能,准备使用协程(asyncio)来开发。
0x01 基于AsyncSSH开发一个最简单的SSH服务端 在调研了几个开源的python SSH库后,最终选择了AsyncSSH 。这个库基于asyncio开发,符合我们的要求,同时扩展性也比较好。
下面实现了一个使用固定账号密码登录的SSH服务器,登录成果后会打印一串字符串,并退出:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 import asyncioimport asyncsshasync def start_ssh_server (): def handle_client (process ): process.stdout.write("Welcome to my SSH server, byebye!\n" ) process.exit(0 ) class MySSHServer (asyncssh.SSHServer): def __init__ (self ): self ._conn = None def password_auth_supported (self ): return True def validate_password (self, username, password ): return username == "drunkdream" and password == "123456" def connection_made (self, conn ): print ("Connection created" , conn.get_extra_info("peername" )[0 ]) self ._conn = conn def connection_lost (self, exc ): print ("Connection lost" , exc) await asyncssh.create_server( MySSHServer, "" , 2222 , server_host_keys=["skey" ], process_factory=handle_client, ) await asyncio.sleep(1000 ) loop = asyncio.get_event_loop() loop.run_until_complete(start_ssh_server())
server_host_keys是服务端的私钥文件列表,用于在建立连接时验证服务端的合法性;在第一次连接时客户端会弹出验证指纹的提示,选择yes后会将指纹保存到本地,下次连接时会验证指纹是否匹配,不匹配会报错。
1 2 3 The authenticity of host '[127.0.0.1]:2222 ([127.0.0.1]:2222)' can't be established. RSA key fingerprint is SHA256:nyXXvfYgedKWPRnhl1ss6k+R5cqFleUQu/fDhYYXESI. Are you sure you want to continue connecting (yes/no)?
1 2 3 4 ssh drunkdream@127.0.0.1 -p 2222 Password: Welcome to my SSH server, byebye! Connection to 127.0.0.1 closed.
这样就实现了一个最简单的SSH服务器了,由此可见,使用AsyncSSH开发SSH服务端是非常方便的。
0x02 支持Shell命令 SSH最常用的功能就是远程终端(shell),下面来实现一个支持执行命令的SSH服务:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 async def start_ssh_server (): import asyncssh async def handle_client (process ): proc = await asyncio.create_subprocess_shell( process.command or "bash -i" , stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, close_fds=True , ) stdin = proc.stdin stdout = proc.stdout stderr = proc.stderr tasks = [None , None , None ] while proc.returncode is None : if tasks[0 ] is None : tasks[0 ] = asyncio.ensure_future(process.stdin.read(4096 )) if tasks[1 ] is None : tasks[1 ] = asyncio.ensure_future(stdout.read(4096 )) if tasks[2 ] is None : tasks[2 ] = asyncio.ensure_future(stderr.read(4096 )) done_tasks, _ = await asyncio.wait( tasks, return_when=asyncio.FIRST_COMPLETED ) for task in done_tasks: index = tasks.index(task) assert index >= 0 tasks[index] = None buffer = task.result() if not buffer: return -1 if index == 0 : stdin.write(buffer) elif index == 1 : process.stdout.write(buffer.replace(b"\n" , b"\r\n" )) else : process.stderr.write(buffer.replace(b"\n" , b"\r\n" )) return proc.returncode class MySSHServer (asyncssh.SSHServer): def __init__ (self ): self ._conn = None def password_auth_supported (self ): return True def validate_password (self, username, password ): return username == "drunkdream" and password == "123456" def connection_made (self, conn ): print ("Connection created" , conn.get_extra_info("peername" )[0 ]) self ._conn = conn def connection_lost (self, exc ): print ("Connection lost" , exc) await asyncssh.create_server( MySSHServer, "" , 2222 , server_host_keys=["skey" ], process_factory=lambda process: asyncio.ensure_future(handle_client(process)), encoding=None , line_editor=False ) await asyncio.sleep(1000 )
与前一个版本相比,主要是修改了handle_client实现,变成了一个协程函数,里面创建了子进程,并支持将ssh客户端输入的命令传给子进程,然后将子进程的stdout和stderr转发给ssh客户端。注意到,这里将line_editor参数设置成了False,主要是为了支持实时命令交互。这个参数后面还会详细介绍。
上面的代码在实际使用中发现,对于很快执行完的命令,如:ifconfig等,使用上没什么问题,但是如果输入python命令进入交互式界面,就会卡住没有任务输入。这是因为使用create_subprocess_shell方式创建的子进程不支持pty导致的。
0x03 支持pty pty(pseudo-tty)是伪终端的意思,也就是虚拟了一个终端出来,让进程可以像正常终端一样进行交互(通常情况下通过管道重定向输入输出的进程都无法支持交互式操作)。交互式终端下缓冲模式是无缓冲(字符模式),也就是stdout每次只要有输出就会打印出来;而非交互式终端是行缓冲模式,stdout必须收到\n换行符才会打印出来。
也就是说,如果终端要支持像python交互式命令这样的场景,必须支持pty。python中可以通过sys.stdout.isatty()来判断当前进程是否支持伪终端。
1 2 3 4 5 6 7 8 python -c 'import sys;print(sys.stdout.isatty())' True python -c 'import sys;print(sys.stdout.isatty())' > /tmp/1.txt && cat /tmp/1.txt False python -c 'import pty; pty.spawn(["python", "-c", "import sys;print(sys.stdout.isatty())"])' > /tmp/1.txt && cat /tmp/1.txt True
从上面可以看出,经过重定向之后,isatty返回值变成了False;但是使用pty.spawn函数之后,重定向就不会影响isatty的返回值了。这里的秘密就在于pty库实现了一个虚拟的tty,具体实现原理我们后面有时间再来分析。
因此,可以使用以下代码创建一个支持pty的子进程:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 import ptycmdline = list (shlex.split(command or os.environ.get("SHELL" , "sh" ))) exe = cmdline[0 ] if exe[0 ] != "/" : for it in os.environ["PATH" ].split(":" ): path = os.path.join(it, exe) if os.path.isfile(path): exe = path break pid, fd = pty.fork() if pid == 0 : sys.stdout.flush() try : os.execve(exe, cmdline, os.environ) except Exception as e: sys.stderr.write(str (e)) else : print (os.read(fd, 4096 ))
上面的方法只能支持Linux和MacOS系统,Windows 1809以上版本可以使用以下方法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 cmd = ( "conhost.exe" , "--headless" , "--width" , str (size[0 ]), "--height" , str (size[1 ]), "--" , command or "cmd.exe" , ) proc = await asyncio.create_subprocess_exec( *cmd, stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, )
conhost.exe里使用CreatePseudoConsole等相关函数,实现了伪终端。低版本Windows上就需要使用其它方式来支持了,例如:winpty 。
0x04 行编辑器模式 前面提到,在使用asyncssh.create_server函数创建SSH服务端时,有个line_editor参数设置成了False。这表示关闭了行编辑器模式,也就是说任何输入的字符都会被实时发送给shell进程,一般这种都是shell进程拥有伪终端的情况。
但如果创建的是一个不支持伪终端的shell进程,就必须关闭行编辑器模式,也就是将line_editor置为True。此时,SSH客户端输入的字符会被asyncssh库捕获并进行处理,直到用户按下Enter键的时候,才会将输入一次性发送给shell进程。
具体可以参考文档 。
0x05 支持端口转发 SSH服务器有个非常有用的功能就是端口转发,包括正向端口转发和反向端口转发。使用方法如下:
正向端口转发:
1 ssh -L 127.0.0.1:7778:127.0.0.1:7777 root@1.2.3.4
此时,可以将远程机器上的7777端口映射到本地的7778端口。
反向端口转发:
1 ssh -R 127.0.0.1:7778:127.0.0.1:7777 root@1.2.3.4
此时,可以将本地的7777端口映射到远程机器上的7778端口。
要支持端口转发,只需要MySSHServer类增加connection_requested和server_requested方法即可。
1 2 3 4 5 6 7 async def connection_requested (self, dest_host, dest_port, orig_host, orig_port ): return await self ._conn.forward_connection(dest_host, dest_port) def server_requested (self, listen_host, listen_port ): return True
0x06 支持密钥登录 通常我们登录SSH服务器,更多的是使用密钥方式登录。要开启这个特性只需要增加以下两个方法即可:
1 2 3 4 5 def public_key_auth_supported (self ): return True def validate_public_key (self, username, key ): return True
0x07 总结 使用AsyncSSH库开发SSH服务器还是比较简单的,很多特性都已经封装好了,只要重写一下对应的方法,返回True就可以了。同时,它也提供了高级可定制化的能力,以便实现较为复杂的功能。
完整的SSH服务器代码可以参考:https://github.com/drunkdream/turbo-tunnel/blob/master/turbo_tunnel/ssh.py#L24 。