记录 SSH 的一个 Bug

on under ssh
10 minute read

服务器上错误的命令行

最近在改进 BSSHD,将不被允许的命令打印到日志,但是我遇到了一个不符合预期的输出,比如,在客户端运行 SSH,命令如下:

ssh git@localhost echo "There are spaces in the statement" done

我将命令行使用如下处理后输出:

	fmt.Fprintf(os.Stderr, "%s\n", strings.Join(s.Command(), ";"))

我们在服务器上命令行的输出如下:

echo;There;are;spaces;in;the;statement;done

上述输出与预期完全不一致,通常情况下,命令行的输出应该如下:

echo;There are spaces in the statement;done

我们可以编译一个命令行程序验证一下:

// echo.go
// go build echo.go
package main

import (
  "fmt"
  "os"
  "strings"
)

func main(){
 fmt.Fprintf(os.Stderr, "%s\n", strings.Join(os.Args, ";"))
}

然后运行输出结果如下:

./echo "There are spaces in the statement" done
./echo;There are spaces in the statement;done

这就说明,SSH 的解析出了问题。我们使用的是 github.com/gliderlabs/ssh,这个问题是否是与 gliderlabs/ssh 解析有关,我们不能过早的下结论,我们可以修改 gliderlabs/ssh 的源码验证一番。

gliderlabs/ssh 的命令行解析代码在:https://github.com/gliderlabs/ssh/blob/ef6d89046be36104109e42ac1fee6601f9be95d7/session.go#L217

			var payload = struct{ Value string }{}
			gossh.Unmarshal(req.Payload, &payload)
			sess.cmd, _ = shlex.Split(payload.Value, true)

			// If there's a session policy callback, we need to confirm before
			// accepting the session.
			if sess.sessReqCb != nil && !sess.sessReqCb(sess, req.Type) {
				sess.cmd = nil
				req.Reply(false, nil)
				continue
			}

我们可以在 shlex.Split 前面插入一段代码

		fmt.Fprintf(os.Stderr, "command: [%s]\n", payload.Value)

然后或得 BSSHD 命令行输出:

command: [echo There are spaces in the statement done]
echo;There;are;spaces;in;the;statement;done

而 command 的内容直接从 SSH Exe 请求的加密数据解析出来的,这就意味着 SSH 传入了错误的命令。那么我们翻阅 OpenSSH 源码。

普遍的糟糕

在 OpenSSH 的源码镜像中:https://github.com/openssh/openssh-portable/blob/9edbd7821e6837e98e7e95546cede804dac96754/ssh.c#L1061 有一段代码:

		/* A command has been specified.  Store it into the buffer. */
		for (i = 0; i < ac; i++) {
			if ((r = sshbuf_putf(command, "%s%s",
			    i ? " " : "", av[i])) != 0)
				fatal("%s: buffer error: %s",
				    __func__, ssh_err(r));
		}

这段代码将字符串数组变成了字符串,但忘记了字符串数组中的每一个都有可能含有空格字符,那么在命令行解析之时就会出现与预期不一致的结果。这个问题在 Dropbear SSH 中也出现了:

//https://github.com/mkj/dropbear/blob/cb945f9f670e95305c7c5cc5ff344d1f2707b602/cli-runopts.c#L390
	if (i < (unsigned int)argc) {
		/* Build the command to send */
		cmdlen = 0;
		for (j = i; j < (unsigned int)argc; j++)
			cmdlen += strlen(argv[j]) + 1; /* +1 for spaces */

		/* Allocate the space */
		cli_opts.cmd = (char*)m_malloc(cmdlen);
		cli_opts.cmd[0] = '\0';

		/* Append all the bits */
		for (j = i; j < (unsigned int)argc; j++) {
			strlcat(cli_opts.cmd, argv[j], cmdlen);
			strlcat(cli_opts.cmd, " ", cmdlen);
		}
		/* It'll be null-terminated here */
		TRACE(("cmd is: %s", cli_opts.cmd))
	}

而像 libssh libssh2 以及 go crypto/ssh 的惯用方法也未考虑到命令行存在空格的问题,因此,很多基于这些库实现的客户端也未考虑这个问题。

另外,在 SSH RFC 4252 中,对 exec 命令行的解析,仅仅只有如下一段话:

      byte      SSH_MSG_CHANNEL_REQUEST
      uint32    recipient channel
      string    "exec"
      boolean   want reply
      string    command

   This message will request that the server start the execution of the
   given command.  The 'command' string may contain a path.  Normal
   precautions MUST be taken to prevent the execution of unauthorized
   commands.

SSH 的规范并没有指出命令行的解析格式规则,但实际上,command 的解析应当符合 POSIX 1003.1 的 Shell 部分。当然,显式描述一下会更好。

我觉得 Windows 这一点做的比较好,文档规范比较完备:Parsing C Command-Line Arguments

OpenSSH 服务端的命令行解析

在 OpenSSH SSHD 的源码中,命令行的处理流程如下:

当请求为 exec 时:

//https://github.com/openssh/openssh-portable/blob/9edbd7821e6837e98e7e95546cede804dac96754/session.c#L2221
		if (strcmp(rtype, "shell") == 0) {
			success = session_shell_req(ssh, s);
		} else if (strcmp(rtype, "exec") == 0) {
			success = session_exec_req(ssh, s);
		} else if (strcmp(rtype, "pty-req") == 0) {
			success = session_pty_req(ssh, s);
		} else if (strcmp(rtype, "x11-req") == 0) {
			success = session_x11_req(ssh, s);
		} else if (strcmp(rtype, "auth-agent-req@openssh.com") == 0) {
			success = session_auth_agent_req(ssh, s);
		} else if (strcmp(rtype, "subsystem") == 0) {
			success = session_subsystem_req(ssh, s);
		} else if (strcmp(rtype, "env") == 0) {
			success = session_env_req(ssh, s);
		}

获得 Command 并执行命令:

//https://github.com/openssh/openssh-portable/blob/9edbd7821e6837e98e7e95546cede804dac96754/session.c#L2047
static int
session_exec_req(struct ssh *ssh, Session *s)
{
	u_int success;
	int r;
	char *command = NULL;

	if ((r = sshpkt_get_cstring(ssh, &command, NULL)) != 0 ||
	    (r = sshpkt_get_end(ssh)) != 0)
		sshpkt_fatal(ssh, r, "%s: parse packet", __func__);

	success = do_exec(ssh, s, command) == 0;
	free(command);
	return success;
}

无论是何种途径,在 OpenSSH 中,sshd 启动进程都是通过 sh -c 这样的方式实现的,这就意味着,命令行应当符合 shell 的标准。

// https://github.com/openssh/openssh-portable/blob/9edbd7821e6837e98e7e95546cede804dac96754/session.c#L1681
	if (!command) {
		char argv0[256];

		/* Start the shell.  Set initial character to '-'. */
		argv0[0] = '-';

		if (strlcpy(argv0 + 1, shell0, sizeof(argv0) - 1)
		    >= sizeof(argv0) - 1) {
			errno = EINVAL;
			perror(shell);
			exit(1);
		}

		/* Execute the shell. */
		argv[0] = argv0;
		argv[1] = NULL;
		execve(shell, argv, env);

		/* Executing the shell failed. */
		perror(shell);
		exit(1);
	}
	/*
	 * Execute the command using the user's shell.  This uses the -c
	 * option to execute the command.
	 */
	argv[0] = (char *) shell0;
	argv[1] = "-c";
	argv[2] = (char *) command;
	argv[3] = NULL;
	execve(shell, argv, env);
	perror(shell);
	exit(1);

这里还有一个问题,服务器上的 Shell 可能是 Dash shell,抑或是 Bash Shell,还有可能是 Zsh 等等,不同的 shell 语法的村子一定的差异,对 sh -c '$command' 的解析并不一定相同,这也可能导致更多的不确定性。

尝试修复

我创建了一个 PR:Fix SSH incorrect command line conversion,这个 PR 将命令行参数编码,核心代码如下:

static const char* escape_argument(char *buf, int bufsize, char *arg){
	int len = strlen(arg);
	if (len == 0){
		return "\"\"";
	}
	if(len+2>=bufsize){
		return arg;
	}
	int hasspace, i ,n;
	hasspace = 0;
	n = len;
	for (i=0; i<len; i++){
		switch(arg[i]){
			case '"':
			case '\\':
			n++;
			break;
			case ' ':
			case '\t':
			hasspace =1;
			break;
			default:
			break;
		}
	}
	if(hasspace){
		n+=2;
	}
	if (n == len||bufsize+1<n){
		return arg;
	}
	int j=0;
	int slashes=0;
	if(hasspace){
		buf[j]='"';
		j++;
	}
	for(i=0; i<len; i++){
		switch(arg[i]){
			case '\\':
				slashes++;
				buf[j]=arg[i];
			break;
			case '"':{
				for(;slashes>0;slashes--){
					buf[j]='\\';
					j++;
				}
				buf[j]='\\';
				j++;
				buf[j]=arg[i];
			}
			break;
			default:
				slashes=0;
				buf[j]=arg[i];
			break;
		}
		j++;
	}
	if(hasspace){
		for(;slashes>0;slashes--){
			buf[j]='\\';
			j++;
		}
		buf[j]='"';
		j++;
	}
	buf[j]=0;
	//memchr(, int __c, size_t __n)
	return buf;
}

但 PR 被关闭了,这个问题最终需要 SSH 协议的改进,否则难以修复。而在 OpenSSH BUG 社区实际上早有讨论:http://bugzilla.mindrot.org/show_bug.cgi?id=2283

最后

SSH 并不完美,OpenSSH 也不完美。