转:MySQL:DNS反解析和用户密码比对方式

原文: mp.weixin.qq.com

在 MySQL 中存在一个 DNS 反解析的功能,也就是通过客户端的 IP 地址反解析为 hostname,涉及的设置和参数包含如下:

  • –skip-name-resolve
  • –skip-host-cache
  • host_cache_size

本文主要对 DNS 反解析进行说明,仅供参考。代码版本 5.7.22.

一、本地连接和远端连接

实际上本地连接使用的是 unix 本地 socket(unix domain socket)如下,

1
-S'/tmp/mysql3325.sock'

而远端连接使用的 TCP 连接(TCP socket)如下,

1
-u mytest -p'gelc123' -h 192.168.1.63 -P 3325

实际上这两种连接方式在确认连接方式的时候是有区别的。在 MySQL 中判定这个也显得比较简单,如果连接属性中给定的是 hostname 就是本地连接,如果没有给定就是 TCP 连接。这个在 check_connection 函数的开头就在确认如下:

1
2
3
4
5
 if (!thd->m_main_security_ctx.host().length)     // If TCP/IP connection
  { //如果没有 主机名就是TCP 连接
...
else /* Hostname given means that the connection was on a socket */
//如果

接下面进行描述。

二、DNS 反解析相关内容

这部分和我们 host cache 有关,判定也稍微复杂一些,我们来看看大概流程

如果没有设置 –skip-name-resolve 则进行,则调用函数 ip_to_hostname 进行 DNS 反解析,在 ip_to_hostname 主要如下:

  • 如果是 127.0.0.1 则说明是回环地址,强制反解析为 localhost,然后结束流程
  • 如果没有设置 –skip-name-resolve 则进行,主要是在 host_cache 中进行寻找,如果找到了进行 max_connect_errors 的判定,如果超过了不允许登录,如果找到了当然就结束了。
  • 如果 host_cache 也没找到(或者设置了 –skip-host-cache),则进行实际的 DNS 反解析,实际上核心就是调用的 Linux api getnameinfo,其主要和 / etc/hosts、 /etc/resolv.conf、/etc/nsswitch.conf 等文件相关,其 api 带入的 flag 为 NI_NAMEREQD,那么如果找不到就会返回错误 EAI_NONAME,但是任何 getnameinfo 的报错都会打印日志 (warnings)
1
IP address '%s' could not be resolved: %s
  • 不管 Linux api getnameinfo 解析是否成功还会将这条信息放入到 host_cache 中,以便下次直接在 host_cache 中就能找到。如果解析失败插入到 host_cache 中的 hostname 为 NULL(add_hostname(ip_key, NULL, validated, &errors);)

总的说来 DNS 反解析 host_cache 的作用,就是避免在没有设置 –skip-name-resolve 的情况下,避免重复的调用 Linux api getnameinfo 进行反解析的代价,结合 –skip-host-cache 或者 host_cache_size=0 那么就有如下一些情况发生:

  • –skip-name-resolve 设置了并且 –skip-host-cache 或者 host_cache_size=0 由于 –skip-name-resolve 设置了直接跳过一切的反解析步骤

  • –skip-name-resolve 设置了但是没有设置 –skip-host-cache 或者 host_cache_size=0 由于 –skip-name-resolve 设置了直接跳过一切的反解析步骤

  • –skip-name-resolve 没有设置但是设置 –skip-host-cache 或者 host_cache_size=0 这种情况,虽然用不到使用不到 host_cache,但是每次的反解析直接使用是 Linux api getnameinfo 进行反解析,并且 127.0.0.1 也会反解析为 localhost

  • 都没有设置 那么就严格按照上面的流程进行,127.0.0.1 也会反解析为 localhost,并且在 host cache 中查找,找不到就调用 Linux api getnameinfo 进行反解析

如果解析出现错误比如 / etc/hosts 中没有写相关信息,则报错

1
IP address '%s' could not be resolved: %s

如果反解析到了 hostname,还会设置上下文的 hostname 为解析到 hostname,并且设置 host_or_ip 为解析到 hostname。

而本地连接就简单多了,没有什么解析不解析的,直接指定 hostname 为 localhost 就可以了,并且设置 host_or_ip 为 localhost。

随后反解析的 hostname 和 ip 地址都会供密码插件使用。我们最关心可能是如果反解析失败是否会影响到登录,这也是我最担心的。

三、native_password 插件如何验证密码

实际上这部分和密码插件有很大的关系,我们就看常用的 native_password 插件。经过前面的 DNS 反解析过后,可能解析到 hostname,接下来就是和 user 表中的信息进行匹配了。内部存储的时候会有 3 个变量存在一个叫做 MPVIO_EXT 的 mpvio 上下文中,当然这里面还有很多元素,比如在 user 表中找到的密码串(加盐后)也会存储在其中,我们关注的如下:

1
2
3
4
5
6
7
8
9
ip:客户端的IP地址
host:客户端经过DNS反解析后的hostname
auth_info::host_or_ip :如果DNS反解析到host就是hostname,如果没有就是ip,这个和我们报错信息有关

主要的接口为
check_connection
 ->acl_authenticate
  ->do_auth_once
   ->native_password_authenticate(插件相关)

其中 native_password_authenticate 就是 native_password 插件密码验证的内容,主要完成的工作如下:

  • 连接握手
  • 根据 user 表中的信息匹配用户,查询密码
  • 验证密码

这里我们需要关注的是其如何查询 user 表中密码的。实际上这个动作,会根据输入的用户和其(hostname 或者 ip)进行验证,因此即便是没有反解析到 hostname,客户端的 ip 是一定有的,但是查找 user 信息的时候就是 @ip 这种形式,而不是 @hostname,言外之意如果你的用户为 test@hostname,但是由于 DNS 反解析失败,那么只能根据 ip 进行查找了。我们来看看这部分,实际上在函数 find_mpvio_user 中重点如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
find_mpvio_user:
  for (ACL_USER *acl_user_tmp= acl_users->begin();
       acl_user_tmp != acl_users->end(); ++acl_user_tmp)//循环acl users
  {
    if ((!acl_user_tmp->user ||  //用户名为空
         !strcmp(mpvio->auth_info.user_name, acl_user_tmp->user)) && //用户名
        acl_user_tmp->host.compare_hostname(mpvio->host, mpvio->ip)) //IP和hostname
    {
      mpvio->acl_user= acl_user_tmp->copy(mpvio->mem_root); //拿到了user中的密码
...
    }
  }
  if (!mpvio->acl_user) //如果查找到的用户为空  假设用户存在 但是密码为空
  {
    /*
      Pretend the user exists; let the plugin decide how to handle
      bad credentials.
    */
    LEX_STRING usr= { mpvio->auth_info.user_name, //传入的用户 
                      mpvio->auth_info.user_name_length };
    mpvio->acl_user= decoy_user(usr, mpvio->mem_root);
...
  }

这里我们明显看到在循环 acl_users,这个信息就是 user 表的内存信息,并且做了排序,排序的规则没去仔细看,但是来自 sql_auth_cache.cc:get_sort 函数,其排列的顺序在函数注释中有如下,

1
2
3
4
   1. no wildcards:没有通配符
   2.strings containg wildcards and non-wildcard characters:包含部分通配符
   3.single muilt-wildcard character('%'):通配符%
   4.empty string:空字符?

这也是我们查找匹配用户的规则。

在这个循环中我们看到条件为(先不考虑空用户):

  • !strcmp(mpvio->auth_info.user_name, acl_user_tmp->user):如果输入的用户名和 user 表中的用户名相等。

  • acl_user_tmp->host.compare_hostname(mpvio->host, mpvio->ip):不考虑 user 表中的空 hostname,那么判定如下:

1
2
3
(host_arg &&
       !wild_case_compare(system_charset_infohost_arghostname)) ||
      (ip_arg && !wild_compare(ip_arghostname, 0))

根据断路原则:

  1. 如果 DNS 反解析没有解析到 hostname 则 host_arg 为 NULL,直接用 ip 进行判定

  2. 如果 DNS 反解析解析到 hostname 则优先比较 user@hostname 这种用户(当然这个还要看排序规则),如果不对才进行 ip 的判定,也就是是否为 user@ip 这种类型

因此我们知道这里有如下结论:

  • 如果 DNS 没有反解析到 hostname,直接用客户端的 ip 和 user 表中的信息进行匹配

  • 如果 user 表中压根就不存在 user@hostname 这种用户,那么还是会通过 user@ip 这种用户进行匹配的。

因此即便我们 MySQL DNS 反解析有问题,通过 user@ip 这种用户登录是没有问题的,但是前提是你建立的用户是 user@ip 这种形式的。这里还需要注意一点如果 user 表中没有用户匹配到,那么内存信息中是一个没有密码的用户,这种用户在进行密码校验的时候依旧报错密码不对,也就是如下代码:

1
2
3
4
5
6
7
8
9
native_password_authenticate:
  info->password_used= PASSWORD_USED_YES; //是否使用了密码
  if (pkt_len == SCRAMBLE_LENGTH)
  {
    if (!mpvio->acl_user->salt_len)
      DBUG_RETURN(CR_AUTH_USER_CREDENTIALS); //如果收到的有密码 ,但是user中没有,则报错
    DBUG_RETURN(check_scramble(pkt, mpvio->scramble, mpvio->acl_user->salt) ?
                CR_AUTH_USER_CREDENTIALS : CR_OK); //验证密码
  }

一旦用户匹配到了密码也就定下来了,那么需要对输入的密码进行判定,这密码判定实际上在 check_scramble 中(如上),它输入的刚好就是通过 socket 读取到了密码和在 user 表中找到的密码,然后进行密码的比对,如果密码不对就会报错。

四、相关场景和报错信息

有了前面的分析,我们来看看几个相关的场景。DNS 反解析成功还是失败通常和主机的 / etc/hosts 相关,这个前面已经说过了。

  • DNS 反解析失败,用户是 user@hostname 的定义 这种情况首先日志报警为 IP address could not be resolved,主机名为 NULL,并且插入到 host_cache 中,密码验证使用 ip 进行查询,但是用户为 hostname,因此找不到相关的信息,直接按没有密码进行处理,也就是密码错误。这种情况下,如果接着在主机的 / etc/hosts 中进行添加相应的 IP 和主机名,再次登录依旧不行,因为 host_cache 已经缓存了,如下,
1
2
3
4
5
6
mysql> select * from performance_schema.host_cache \G
*************************** 1. row ***************************
                                        IP: 192.168.1.101
                                      HOST: NULL
                            HOST_VALIDATED: YES
...

然后根据流程如果缓存命中了,就不会进行实际的解析了,依旧报错,需要 flush hosts 一次。

  • DNS 反解析失败,用户是 user@IP 的定义 这种情况下日志报警为 IP address could not be resolved,主机名为 NULL,并且插入到 host_cache 中,密码验证使用 ip 进行查询,发现用户存在,校验密码后,登录成功。

  • DNS 反解析成功,用户是 user@IP 的定义 这种情况下当然也没有任何问题,因为校验用户的时候也会校验 user@IP 这种用户,只是在 user@hostname 校验过后。

  • DNS 反解析成功,用户是 user@hostname 的定义 这种就是正常的情况了,没啥说的,肯定没问题的。

  • 本地登录使用 -h 127.0.0.1 -P 3306 这种方式,用户为 root@localhost 这也是最常见的一种登录方式,如果发现这种能登录上去,那么说明至少没有设置 –skip-name-resolve,因为一旦设置了,TCP 连接下的 127.0.0.1 这个回环地址不会解析为 localhost,因此登录是失败的。我们需要做的就是用 - S’’ 的方式登录就可以了,因为本地连接始终为 localhost。如下:

1
2
3
4
5
6
7
8
9
开启DNS反解析的时候
[root@mgr3 ~]# /opt/my_mysql/bin/mysql -uroot  -h 127.0.0.1 -P 3325
Welcome to the MySQL monitor.  Commands end with ; or \g.
...
mysql> exit
Bye
关闭DNS反解析的时候
[root@mgr3 ~]# /opt/my_mysql/bin/mysql -uroot  -h 127.0.0.1 -P 3325
ERROR 1045 (28000): Access denied for user 'root'@'127.0.0.1' (using password: NO)

其次,需要注意的是,即便是用户不存在,我们在上面解析中,发现用户没找到的情况,是虚构的一个没有密码的用户,那么在验证密码的时候肯定是错误的,因此也是密码错误,并且返回的错误中如果 DNS 反解析成功了返回的是 hostname,如果失败返回的是 IP 地址如下(这来自前面我们说的 host_or_ip 这个变量):

1
2
3
4
5
6
7
8
9
10
11
12
解析失败:
[root@mgr1 tmp]# /opt/mysql/mysql3310/install/mysql31/bin/mysql -u mytest -p'gelc1234' -h 192.168.1.63 -P 3325
mysql: [WarningUsing a password on the command line interface can be insecure.
ERROR 1045 (28000): Access denied for user 'mytest'@'192.168.1.101' (using password: YES)
解析成功:
[root@mgr1 tmp]# /opt/mysql/mysql3310/install/mysql31/bin/mysql -u mytest111 -p'gelc1234' -h 192.168.1.63 -P 3325
mysql: [WarningUsing a password on the command line interface can be insecure.
ERROR 1045 (28000): Access denied for user 'mytest111'@'mgr10' (using password: YES)

日志如下:
2022-10-19T07:58:56.57180379 [Note] Access denied for user 'mytest111'@'192.168.1.101' (using password: YES)
2022-10-19T07:59:11.04172380 [Note] Access denied for user 'mytest111'@'mgr10' (using password: YES)

注意 @后面的部分就是表名是否 DNS 反解析成功了。

五、总结

为什么需要看看这个东西呢,因为虽然我自己在使用时候是直接 –skip-host-cache 和 –skip-name-resolve 的设置,但是很多朋友不是的,是开启了 DNS 反解析功能的,因此做了一些学习。总而言之,这个 DNS 反解析真的麻烦(吐血狂喷),跳过反解析就是最好的安慰。唯一的好处我觉得就是能够让用户 user@hostname 这种用户登录到数据库。但是这一般不是必须的,因此建议直接全部跳过 DNS 反解析这部分,建立的用户全部写 IP 或者通配符,也不会有很多很多的歧义。另外如果是开启了反解析,我们依旧可以使用 user@IP 这种用户登录(建议都是这种类型的用户),因为从流程上看,即便反解析失败或者没有 user@hostname 这种用户依旧会通过 IP 进行用户查找。但是解析失败可能出现 DNS 反解析比较慢的问题,因此还是建议在 / etc/hosts 配置所有客户端的地址。

六、部分代码流程

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
check_connection

 -> if (!thd->m_main_security_ctx.host().length)
   如果是TCP连接
   ->if (!(specialflag & SPECIAL_NO_RESOLVE))
     没有指定了选项 --skip-name-resolve
     ->ip_to_hostname  
      (ip_storage=0x7fff80000a28, ip_string=0x7fff80007f90 "192.168.1.101", hostname=0x7fff9f211cf8, connect_errors=0x7fff9f211d1c)
       -> is_ip_loopback(ip)
          如果是回环地址127.0.0.1
          ->*hostname= (char *) my_localhost;
          直接将hostname设置为localhost,直接return 0 
       -> 定义一个ip_key的内存,并且将IP地址传入到这个内存
          prepare_hostname_cache_key(ip_string, ip_key)
          将ip的字符串传入到这个内存中
       -> 如果没有跳过 skip host cache ,设置参数 
          (specialflag & SPECIAL_NO_HOST_CACHE)
          -> 在缓存中查找
             hostname_cache_search(ip_key)
            -> 如果找到,找到的对象为entry
               if (entry) ....
               
               返回得到的hostname
              
       如果没有找到,则进行实际的解析,注意这里即便是设置了skip host cache也会进行实际的解析。
       -> 定义hostname_buffer 用于存储解析到的hostname
       -> err_code= vio_getnameinfo(ip, hostname_buffer, NI_MAXHOST, NULL, 0, NI_NAMEREQD); 
         通过IP反解析hostname
          -> vio_getnameinfo  getnameinfo 主要是通过/etc/hosts和/etc/service等进行域名解析,解析到登入IP的域名
             ->getnameinfo 带入 NI_NAMEREQD
               如果找不到hosts配置则像错误一样对待,返回errno
               如果找到则进入hostname_buffer
       -> 如果err_code存在
          报出warnings
          sql_print_warning("Host name '%s' could not be resolved: %s",
                           hostname_buffer,
                           gai_strerror(err_code));
         如果返回错误为 EAI_NONAME ,就是没解析到,为getnameinfo的返回值,设置validated为ture
         如果返回错误为其他,则设置validated为false
         add_hostname
         -> 加入到host cache中如下,
           mysql> select *from host_cache \G
                                             IP: 192.168.1.101
                                           HOST: NULL
                                 HOST_VALIDATED: YES
         直接返回0,这里导致一个异常,即便密码和/etc/hosts 加入后依旧存在,见下文
     ->thd->m_main_security_ctx.assign_host(host, host? strlen(host) : 0
       将解析到的 hostname写入到THD的属性m_host中
     ->main_sctx_host= thd->m_main_security_ctx.host();
       将hostname和长度封装到main_sctx_host中
     ->解决hostname超长的问题 最大长度为60(HOSTNAME_LENGTH)字节一般不一超出  
 ->如果是本地连接,主机名为localhost
   连接认证走的是localhost

以上。

感谢关注 “MySQL 解决方案工程师”