MySQL:DNS反解析和用户密码比对方式
在MySQL中存在一个DNS反解析的功能,也就是通过客户端的IP地址反解析为hostname,涉及的设置和参数包含如下:
- --skip-name-resolve
- --skip-host-cache
- host_cache_size
本文主要对DNS反解析进行说明,仅供参考。代码版本5.7.22.
一、本地连接和远端连接
实际上本地连接使用的是unix本地socket(unix domain socket)如下,
-S'/tmp/mysql3325.sock'
而远端连接使用的TCP连接(TCP socket)如下,
-u mytest -p'gelc123' -h 192.168.1.63 -P 3325
实际上这两种连接方式在确认连接方式的时候是有区别的。在MySQL中判定这个也显得比较简单,如果连接属性中给定的是hostname就是本地连接,如果没有给定就是TCP 连接。这个在check_connection函数的开头就在确认如下:
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)
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中没有写相关信息,则报错
IP address '%s' could not be resolved: %s
如果反解析到了hostname,还会设置上下文的hostname为解析到hostname,并且设置host_or_ip为解析到hostname。
而本地连接就简单多了,没有什么解析不解析的,直接指定hostname为localhost就可以了,
image.png
并且设置host_or_ip为localhost。
随后反解析的hostname和ip地址都会供密码插件使用。我们最关心可能是如果反解析失败是否会影响到登录,这也是我最担心的。
三、native_password 插件如何验证密码
实际上这部分和密码插件有很大的关系,我们就看常用的native_password插件。
经过前面的DNS反解析过后,可能解析到hostname,接下来就是和user表中的信息进行匹配了。
内部存储的时候会有3个变量存在一个叫做MPVIO_EXT的mpvio上下文中,当然这里面还有很多元素,比如在user表中找到的密码串(加盐后)也会存储在其中,我们关注的如下:
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中重点如下:
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. 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,那么判定如下:
(host_arg &&
!wild_case_compare(system_charset_info, host_arg, hostname)) ||
(ip_arg && !wild_compare(ip_arg, hostname, 0))
根据断路原则:
- 如果DNS反解析没有解析到hostname则host_arg为NULL,直接用ip进行判定
- 如果DNS反解析解析到hostname则优先比较 user@hostname这种用户(当然这个还要看排序规则),如果不对才进行ip的判定,也就是是否为user@ip这种类型
因此我们知道这里有如下结论:
- 如果DNS没有反解析到hostname,直接用客户端的ip和user表中的信息进行匹配
- 如果user表中压根就不存在user@hostname这种用户,那么还是会通过user@ip这种用户进行匹配的。
因此即便我们MySQL DNS反解析有问题,通过user@ip这种用户登录是没有问题的,但是前提是你建立的用户是user@ip这种形式的。
这里还需要注意一点如果user表中没有用户匹配到,那么内存信息中是一个没有密码的用户,这种用户在进行密码校验的时候依旧报错密码不对,也就是如下代码:
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已经缓存了,如下,
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。如下:
开启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这个变量):
解析失败:
[root@mgr1 tmp]# /opt/mysql/mysql3310/install/mysql31/bin/mysql -u mytest -p'gelc1234' -h 192.168.1.63 -P 3325
mysql: [Warning] Using 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: [Warning] Using 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.571803Z 79 [Note] Access denied for user 'mytest111'@'192.168.1.101' (using password: YES)
2022-10-19T07:59:11.041723Z 80 [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配置所有客户端的地址。
六、部分代码流程
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
以上。。