创建一个简单的SSH服务器

0x00 前言

为了加深对SSH协议的理解,准备自己实现一个SSH服务端,需要同时支持WindowsLinuxMacOS三大系统。为了尽量提升性能,准备使用协程(asyncio)来开发。

0x01 基于AsyncSSH开发一个最简单的SSH服务端

在调研了几个开源的python SSH库后,最终选择了AsyncSSH。这个库基于asyncio开发,符合我们的要求,同时扩展性也比较好。

下面实现了一个使用固定账号密码登录的SSH服务器,登录成果后会打印一串字符串,并退出:

  1. import asyncio
  2. import asyncssh
  3. async def start_ssh_server():
  4. def handle_client(process):
  5. process.stdout.write("Welcome to my SSH server, byebye!\n")
  6. process.exit(0)
  7. class MySSHServer(asyncssh.SSHServer):
  8. def __init__(self):
  9. self._conn = None
  10. def password_auth_supported(self):
  11. return True
  12. def validate_password(self, username, password):
  13. return username == "drunkdream" and password == "123456"
  14. def connection_made(self, conn):
  15. print("Connection created", conn.get_extra_info("peername")[0])
  16. self._conn = conn
  17. def connection_lost(self, exc):
  18. print("Connection lost", exc)
  19. await asyncssh.create_server(
  20. MySSHServer,
  21. "",
  22. 2222,
  23. server_host_keys=["skey"],
  24. process_factory=handle_client,
  25. )
  26. await asyncio.sleep(1000)
  27. loop = asyncio.get_event_loop()
  28. loop.run_until_complete(start_ssh_server())
COPY

server_host_keys是服务端的私钥文件列表,用于在建立连接时验证服务端的合法性;在第一次连接时客户端会弹出验证指纹的提示,选择yes后会将指纹保存到本地,下次连接时会验证指纹是否匹配,不匹配会报错。

  1. The authenticity of host '[127.0.0.1]:2222 ([127.0.0.1]:2222)' can't be established.
  2. RSA key fingerprint is SHA256:nyXXvfYgedKWPRnhl1ss6k+R5cqFleUQu/fDhYYXESI.
  3. Are you sure you want to continue connecting (yes/no)?
COPY
  1. ssh drunkdream@127.0.0.1 -p 2222
  2. Password:
  3. Welcome to my SSH server, byebye!
  4. Connection to 127.0.0.1 closed.
COPY

这样就实现了一个最简单的SSH服务器了,由此可见,使用AsyncSSH开发SSH服务端是非常方便的。

0x02 支持Shell命令

SSH最常用的功能就是远程终端(shell),下面来实现一个支持执行命令的SSH服务:

  1. async def start_ssh_server():
  2. import asyncssh
  3. async def handle_client(process):
  4. proc = await asyncio.create_subprocess_shell(
  5. process.command or "bash -i",
  6. stdin=asyncio.subprocess.PIPE,
  7. stdout=asyncio.subprocess.PIPE,
  8. stderr=asyncio.subprocess.PIPE,
  9. close_fds=True,
  10. )
  11. stdin = proc.stdin
  12. stdout = proc.stdout
  13. stderr = proc.stderr
  14. tasks = [None, None, None]
  15. while proc.returncode is None:
  16. if tasks[0] is None:
  17. tasks[0] = asyncio.ensure_future(process.stdin.read(4096))
  18. if tasks[1] is None:
  19. tasks[1] = asyncio.ensure_future(stdout.read(4096))
  20. if tasks[2] is None:
  21. tasks[2] = asyncio.ensure_future(stderr.read(4096))
  22. done_tasks, _ = await asyncio.wait(
  23. tasks, return_when=asyncio.FIRST_COMPLETED
  24. )
  25. for task in done_tasks:
  26. index = tasks.index(task)
  27. assert index >= 0
  28. tasks[index] = None
  29. buffer = task.result()
  30. if not buffer:
  31. return -1
  32. if index == 0:
  33. stdin.write(buffer)
  34. elif index == 1:
  35. process.stdout.write(buffer.replace(b"\n", b"\r\n"))
  36. else:
  37. process.stderr.write(buffer.replace(b"\n", b"\r\n"))
  38. return proc.returncode
  39. class MySSHServer(asyncssh.SSHServer):
  40. def __init__(self):
  41. self._conn = None
  42. def password_auth_supported(self):
  43. return True
  44. def validate_password(self, username, password):
  45. return username == "drunkdream" and password == "123456"
  46. def connection_made(self, conn):
  47. print("Connection created", conn.get_extra_info("peername")[0])
  48. self._conn = conn
  49. def connection_lost(self, exc):
  50. print("Connection lost", exc)
  51. await asyncssh.create_server(
  52. MySSHServer,
  53. "",
  54. 2222,
  55. server_host_keys=["skey"],
  56. process_factory=lambda process: asyncio.ensure_future(handle_client(process)),
  57. encoding=None,
  58. line_editor=False
  59. )
  60. await asyncio.sleep(1000)
COPY

与前一个版本相比,主要是修改了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. python -c 'import sys;print(sys.stdout.isatty())'
  2. True
  3. python -c 'import sys;print(sys.stdout.isatty())' > /tmp/1.txt && cat /tmp/1.txt
  4. False
  5. python -c 'import pty; pty.spawn(["python", "-c", "import sys;print(sys.stdout.isatty())"])' > /tmp/1.txt && cat /tmp/1.txt
  6. True
COPY

从上面可以看出,经过重定向之后,isatty返回值变成了False;但是使用pty.spawn函数之后,重定向就不会影响isatty的返回值了。这里的秘密就在于pty库实现了一个虚拟的tty,具体实现原理我们后面有时间再来分析。

因此,可以使用以下代码创建一个支持pty的子进程:

  1. import pty
  2. cmdline = list(shlex.split(command or os.environ.get("SHELL", "sh")))
  3. exe = cmdline[0]
  4. if exe[0] != "/":
  5. for it in os.environ["PATH"].split(":"):
  6. path = os.path.join(it, exe)
  7. if os.path.isfile(path):
  8. exe = path
  9. break
  10. pid, fd = pty.fork()
  11. if pid == 0:
  12. # child process
  13. sys.stdout.flush()
  14. try:
  15. os.execve(exe, cmdline, os.environ)
  16. except Exception as e:
  17. sys.stderr.write(str(e))
  18. else:
  19. # parent process
  20. print(os.read(fd, 4096))
COPY

上面的方法只能支持Linux和MacOS系统,Windows 1809以上版本可以使用以下方法:

  1. cmd = (
  2. "conhost.exe",
  3. "--headless",
  4. "--width",
  5. str(size[0]),
  6. "--height",
  7. str(size[1]),
  8. "--",
  9. command or "cmd.exe",
  10. )
  11. proc = await asyncio.create_subprocess_exec(
  12. *cmd,
  13. stdin=asyncio.subprocess.PIPE,
  14. stdout=asyncio.subprocess.PIPE,
  15. stderr=asyncio.subprocess.PIPE,
  16. )
COPY

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
COPY

此时,可以将远程机器上的7777端口映射到本地的7778端口。

反向端口转发:

  1. ssh -R 127.0.0.1:7778:127.0.0.1:7777 root@1.2.3.4
COPY

此时,可以将本地的7777端口映射到远程机器上的7778端口。

要支持端口转发,只需要MySSHServer类增加connection_requestedserver_requested方法即可。

  1. async def connection_requested(self, dest_host, dest_port, orig_host, orig_port):
  2. # 正向端口转发
  3. return await self._conn.forward_connection(dest_host, dest_port)
  4. def server_requested(self, listen_host, listen_port):
  5. # 反向端口转发
  6. return True
COPY

0x06 支持密钥登录

通常我们登录SSH服务器,更多的是使用密钥方式登录。要开启这个特性只需要增加以下两个方法即可:

  1. def public_key_auth_supported(self):
  2. return True
  3. def validate_public_key(self, username, key):
  4. return True
COPY

0x07 总结

使用AsyncSSH库开发SSH服务器还是比较简单的,很多特性都已经封装好了,只要重写一下对应的方法,返回True就可以了。同时,它也提供了高级可定制化的能力,以便实现较为复杂的功能。

完整的SSH服务器代码可以参考:https://github.com/drunkdream/turbo-tunnel/blob/master/turbo_tunnel/ssh.py#L24

分享

Gitalking ...