从 TLS 客户端 hello 中提取服务器名称指示 (SNI)



您将如何从TLS客户端您消息中提取服务器名称指示(SNI(。我目前正在努力理解这个关于TLS扩展的非常神秘的RFC 3546,其中定义了SNI。

到目前为止,我所了解的事情:

    主机采用 utf8 编码,
  • 当您对缓冲区进行 utf8 编码时可读。
  • 主机前面有一个字节,它决定了它的长度。

如果我能找出该长度字节的确切位置,提取SNI将非常简单。但是我首先如何到达该字节?

我在 sniproxy 中这样做了,在 Wireshark 中检查 TLS 客户端 hello 数据包,同时阅读 RFC 是一个很好的方法。这并不难,只是您必须跳过许多可变长度字段并检查您是否具有正确的元素类型。

我现在正在进行测试,并且有这个带注释的示例包可能会有所帮助:

const unsigned char good_data_2[] = {
    // TLS record
    0x16, // Content Type: Handshake
    0x03, 0x01, // Version: TLS 1.0
    0x00, 0x6c, // Length (use for bounds checking)
        // Handshake
        0x01, // Handshake Type: Client Hello
        0x00, 0x00, 0x68, // Length (use for bounds checking)
        0x03, 0x03, // Version: TLS 1.2
        // Random (32 bytes fixed length)
        0xb6, 0xb2, 0x6a, 0xfb, 0x55, 0x5e, 0x03, 0xd5,
        0x65, 0xa3, 0x6a, 0xf0, 0x5e, 0xa5, 0x43, 0x02,
        0x93, 0xb9, 0x59, 0xa7, 0x54, 0xc3, 0xdd, 0x78,
        0x57, 0x58, 0x34, 0xc5, 0x82, 0xfd, 0x53, 0xd1,
        0x00, // Session ID Length (skip past this much)
        0x00, 0x04, // Cipher Suites Length (skip past this much)
            0x00, 0x01, // NULL-MD5
            0x00, 0xff, // RENEGOTIATION INFO SCSV
        0x01, // Compression Methods Length (skip past this much)
            0x00, // NULL
        0x00, 0x3b, // Extensions Length (use for bounds checking)
            // Extension
            0x00, 0x00, // Extension Type: Server Name (check extension type)
            0x00, 0x0e, // Length (use for bounds checking)
            0x00, 0x0c, // Server Name Indication Length
                0x00, // Server Name Type: host_name (check server name type)
                0x00, 0x09, // Length (length of your data)
                // "localhost" (data your after)
                0x6c, 0x6f, 0x63, 0x61, 0x6c, 0x68, 0x6f, 0x73, 0x74,
            // Extension
            0x00, 0x0d, // Extension Type: Signature Algorithms (check extension type)
            0x00, 0x20, // Length (skip past since this is the wrong extension)
            // Data
            0x00, 0x1e, 0x06, 0x01, 0x06, 0x02, 0x06, 0x03,
            0x05, 0x01, 0x05, 0x02, 0x05, 0x03, 0x04, 0x01,
            0x04, 0x02, 0x04, 0x03, 0x03, 0x01, 0x03, 0x02,
            0x03, 0x03, 0x02, 0x01, 0x02, 0x02, 0x02, 0x03,
            // Extension
            0x00, 0x0f, // Extension Type: Heart Beat (check extension type)
            0x00, 0x01, // Length (skip past since this is the wrong extension)
            0x01 // Mode: Peer allows to send requests
};

使用 WireShark 并通过添加过滤器tcp port 443仅捕获 TLS (SSL( 包。然后找到"客户端您好"消息。您可以在下面看到其原始数据。

扩展Secure Socket Layer -> TLSv1.2 Record Layer: Handshake Protocol: Client Hello -> ...
你会看到Extension: server_name -> Server Name Indication extension.握手包中的服务器名称未加密。

https://i.stack.imgur.com/qt0gu.png

对于任何感兴趣的人,这是C/C++代码的暂定版本。到目前为止,它已经奏效了。该函数返回服务器名称在包含客户端 Hello 的字节数组中的位置以及 len 参数中名称的长度。

char *get_TLS_SNI(unsigned char *bytes, int* len)
{
    unsigned char *curr;
    unsigned char sidlen = bytes[43];
    curr = bytes + 1 + 43 + sidlen;
    unsigned short cslen = ntohs(*(unsigned short*)curr);
    curr += 2 + cslen;
    unsigned char cmplen = *curr;
    curr += 1 + cmplen;
    unsigned char *maxchar = curr + 2 + ntohs(*(unsigned short*)curr);
    curr += 2;
    unsigned short ext_type = 1;
    unsigned short ext_len;
    while(curr < maxchar && ext_type != 0)
    {
        ext_type = ntohs(*(unsigned short*)curr);
        curr += 2;
        ext_len = ntohs(*(unsigned short*)curr);
        curr += 2;
        if(ext_type == 0)
        {
            curr += 3;
            unsigned short namelen = ntohs(*(unsigned short*)curr);
            curr += 2;
            *len = namelen;
            return (char*)curr;
        }
        else curr += ext_len;
    }
    if (curr != maxchar) throw std::exception("incomplete SSL Client Hello");
    return NULL; //SNI was not present
}

我注意到域总是以两个零字节和一个长度字节为前缀。也许它是无符号的 24 位整数,但我无法测试它,因为我的 DNS 服务器不允许超过 77 个字符的域名。

有了这些知识,我想出了这个(Node.js(代码。

function getSNI(buf) {
  var sni = null
    , regex = /^(?:[a-z0-9-]+.)+[a-z]+$/i;
  for(var b = 0, prev, start, end, str; b < buf.length; b++) {
    if(prev === 0 && buf[b] === 0) {
      start = b + 2;
      end   = start + buf[b + 1];
      if(start < end && end < buf.length) {
        str = buf.toString("utf8", start, end);
        if(regex.test(str)) {
          sni = str;
          continue;
        }
      }
    }
    prev = buf[b];
  }
  return sni;
}

此代码查找两个零字节的序列。如果找到一个,则假定以下字节是长度参数。它检查长度是否仍在缓冲区的边界内,如果是,则以 UTF-8 格式读取字节序列。稍后,可以对数组进行正则表达式并提取域。

效果非常好!不过,我还是注意到了一些奇怪的事情。

'�n�u0014u0000�u0000�u00009u00008�u000f�u0005u0000�u00005�u0007�t�u0011�u0013u0000Eu0000Du0000fu00003u00002�f�u000e�u0002�u0004u0000�u0000Au0000u0005u0000u0004u0000/�b�u0012u0000u0016u0000u0013�r�u0003��u0000n'
'u0000u0015u0000u0000u0012test.cubixcraft.de'
'test.cubixcraft.de'
'u0000bu0000u0006u0000u0017u0000u0018u0000u0019'
'u0000u0005u0001u0000u0000'

无论我选择哪个子域,该域始终是两次目标。SNI 字段似乎嵌套在另一个字段中。

我愿意接受建议和改进! :)

我把它变成了一个节点模块,供每个人使用,谁关心:sni。

最新更新