Libevent2域名随机大小写DNS解析失败的解决

被evdns和国内电信厂商不遵守RFC1034中3.5节规矩的DNS服务器坑了一晚上.

一个OpenWrt MT7620路由器项目中用了libevent2的DNS异步解析,最近出现了点小BUG,经过一段时间的调试和分析,终于把问题给Kill掉了.特在此稍作总结:

在调试分析得知原因后,竟然我的问题和一个网友之前遇到的问题如出一辙,下面是转载直网友的博文 (via https://www.sunchangming.com/blog/post/4601.html) 结合我调试的实际情况稍作改动的记录:

libevent内置的异步DNS解析器采用了一种叫做DNS-0x20 bit encoding的hacking手段来防止DNS投毒攻击。但是这种做法是有问题的,在某些特殊环境(比如国内网络)下会导致DNS解析全部失败。

DNS协议对域名大小写的规定:
数据库中,域名应该是不分大小写的。

DNS协议的RFC标准是http://tools.ietf.org/html/rfc1034。在它的3.5节中特别有一段话:

“Note that while upper and lower case letters are allowed in domain names, no significance is attached to the case. That is, two names with the same spelling but different case are to be treated as if identical.”

DNS答复中,应保持域名大小写不变。DNS消息有三种类型 请求、答复、更新。请求包中域名是怎样的大小写,答复包中也应该保持一致。例如,我要查www.baidU.com,那收到的答复消息中域名也应该是www.baidU.com,而不是www.baidu.com或www.Baidu.com。而且在数据库中,www.baidU.com、www.baidu.com和www.Baidu.com应该是同一条记录。

实际上,目前有很多很多的DNS Server并不遵守这一标准。

下面是我做的实验。我在我的MacBook上用nslookup www.Baidu.com 202.106.0.20向联通的DNS(202.106.0.20)发起了请求对www.Baidu.com的查询请求,并用wireshark抓取来往的包。

请求:

0000 00 02 01 00 00 01 00 00 00 00 00 00 03 77 77 77 .............www

0010 05 42 61 69 64 75 03 63 6f 6d 00 00 01 00 01 .Baidu.com.....

答复:

0000 00 02 80 00 00 01 00 01 00 00 00 00 03 77 77 77 .............www

0010 05 62 61 69 64 75 03 63 6f 6d 00 00 01 00 01 c0 .baidu.com......

0020 0c 00 01 00 01 00 00 01 f4 00 04 77 4b d9 38 ...........wK.8

可以很明显的看出答复的包中,域名被全小写了。据我猜测,不是联通DNS Server的问题,而是中途被我的ISP搞了鬼。

什么是DNS-0x20 encoding?
它是一种未被标准化的防伪机制。DNS尽管本来就有TransactionID,但是很容易被伪造。于是就有人想,如果我们把请求包中的域名进行随机大小写,并且DNS server确实遵守rfc 1034规范,按照原样返回,那么这样就增加了中途投毒的难度。因为中途如果有人想要篡改,就得维持一个session来记录当初发过去的包是大写还是小写。ascii表中,大写字母从0x41开始,小写字母从0x61开始,恰好相差0x20。即 'A' | 0x20 = 'a'; 所以这种随机大小写的方式被称为0x20 encoding。但是我觉得,到底是你太笨还是你把别人想的太笨了?

实际上,google在构建它的public dns server(8.8.8.8)时确实也采用了这种方式。但是它深知现实与理想是不一致的,所以采用了一套白名单机制。只有位于白名单中的DNS server,才用0x20 encoding。google说这样的请求占了它70%以上的流量(“The whitelisted nameservers comprise more than 70% of our traffic.” from https://developers.google.com/speed/public-dns/docs/security)

Libevent如何实现DNS-0x20 encoding
Libevent是一套非常流行的网络库,比如Chrome、memcached就是依靠它实现非阻塞IO。在编写非阻塞IO程序时,很重要的一点是连域名解析也必须是异步的,也就是说glibc里面的那些域名解析函数都不能用。为此,libevent内置了一套异步DNS解析器,叫evdns。但是evdns的实现就要简单粗暴的很多。它默认对所有请求都采用了0x20 encoding。

evdns_base结构体内部有一个字段:


/* true iff we will use the 0x20 hack to prevent poisoning attacks. */

int global_randomize_case;

这个字段在evdns_base_new这个函数中被初始化为1,所以这个功能默认是打开的。

global_randomize_case这个开关会影响如何构造DNS request。

在evdns.c的request_new这个函数中,

if (base->global_randomize_case) {
  unsigned i;
  char randbits[(sizeof(namebuf)+7)/8];
  strlcpy(namebuf, name, sizeof(namebuf));
  evutil_secure_rng_get_bytes(randbits, (name_len+7)/8);
  for (i = 0; i < name_len; ++i) {
    if (EVUTIL_ISALPHA(namebuf[i])) {
      if ((randbits[i >> 3] & (1<<(i & 7))))
        namebuf[i] |= 0x20;
      else
        namebuf[i] &= ~0x20;
    }
  }
  name = namebuf;
}

它发现如果这个开关是打开的,就会随机的改变域名的大小写。

当从DNS Server收到reply之后,client会调用reply_parse函数来解析。下面是函数栈。

 reply_parse(evdns_base * base, unsigned char * packet, int length)

 nameserver_read(nameserver * ns)

 nameserver_ready_callback(int fd, short events, void * arg)

 event_persist_closure(event_base * base, event * ev)

 event_process_active_single_queue(event_base * base, event_list * activeq) 

 event_process_active(event_base * base)

 event_base_loop(event_base * base, int flags)

 event_base_dispatch(event_base * event_base)

int reply_parse(struct evdns_base *base, u8 *packet, int length)这个函数中,它要在请求包和答复包中按照DNS lable做查找匹配。

下面的tmp_name来自reply,cmp_name来自request。

int name_matches=0;
//...
if (base->global_randomize_case) {
if (strcmp(tmp_name, cmp_name) == 0)
  name_matches = 1;
}else {
  if (evutil_ascii_strcasecmp(tmp_name, cmp_name) == 0) 
    name_matches = 1;
}

如果全找完后name_matches是0。那么就是DNS解析失败了。

在libevent的samples目录下有一个小程序,你可以试下

# ./dns-example -v www.baidu.com

INFO: Parsing resolv.conf file /etc/resolv.conf

INFO: Added nameserver 219.239.26.42:53 as 0xbf86d0

INFO: Added nameserver 202.106.0.20:53 as 0xbf88a0

EVUTIL_AI_CANONNAME in example = 2

resolving (fwd) www.baidu.com...

INFO: Resolve requested for www.baidu.com

INFO: Setting timeout for request 0xbf8a70, sent to nameserver 0xbf86d0

INFO: Removing timeout for request 0xbf8a70

www.baidu.com: No answer (66)

不应该是No answser啊!

如果你好奇,既然chrome用的也是libevent,那么为何没这个问题呢?答案是:chrome没用libevent内置的DNS解析器,它只用了基础IO部分。

原博主的解决方法是修改libevent2的源码,关闭那个开关.然后像官方项目发Pull Request,但是可惜的是没有被合并,作者比较有原则.

其实项目作者提供给了我们灵活设置这些选项的接口API的,该API就是:

/* exported function */
int
evdns_base_set_option(struct evdns_base *base,
    const char *option, const char *val)
{
    int res;
    EVDNS_LOCK(base);
    res = evdns_base_set_option_impl(base, option, val, DNS_OPTIONS_ALL);
    EVDNS_UNLOCK(base);
    return res;
}

此方法在include/event2/dns.h中有导出:

/**
  Set the value of a configuration option.
  The currently available configuration options are:
    ndots, timeout, max-timeouts, max-inflight, attempts, randomize-case,
    bind-to, initial-probe-timeout, getaddrinfo-allow-skew.
  In versions before Libevent 2.0.3-alpha, the option name needed to end with
  a colon.
  @param base the evdns_base to which to apply this operation
  @param option the name of the configuration option to be modified
  @param val the value to be set
  @return 0 if successful, or -1 if an error occurred
 */
EVENT2_EXPORT_SYMBOL
int evdns_base_set_option(struct evdns_base *base, const char *option, const char *val);

所以,从该接口中就可以得知,一共有好几个选项是可以设置的.

我们回去看看上面方法内的实现函数evdns_base_set_option_impl的具体实现:


static int
evdns_base_set_option_impl(struct evdns_base *base,
    const char *option, const char *val, int flags)
{
    ASSERT_LOCKED(base);
    if (str_matches_option(option, "ndots:")) {
        const int ndots = strtoint(val);
        if (ndots == -1) return -1;
        if (!(flags & DNS_OPTION_SEARCH)) return 0;
        log(EVDNS_LOG_DEBUG, "Setting ndots to %d", ndots);
        if (!base->global_search_state) base->global_search_state = search_state_new();
        if (!base->global_search_state) return -1;
        base->global_search_state->ndots = ndots;
    } else if (str_matches_option(option, "timeout:")) {
        struct timeval tv;
        if (strtotimeval(val, &tv) == -1) return -1;
        if (!(flags & DNS_OPTION_MISC)) return 0;
        log(EVDNS_LOG_DEBUG, "Setting timeout to %s", val);
        memcpy(&base->global_timeout, &tv, sizeof(struct timeval));
    } else if (str_matches_option(option, "getaddrinfo-allow-skew:")) {
        struct timeval tv;
        if (strtotimeval(val, &tv) == -1) return -1;
        if (!(flags & DNS_OPTION_MISC)) return 0;
        log(EVDNS_LOG_DEBUG, "Setting getaddrinfo-allow-skew to %s",
            val);
        memcpy(&base->global_getaddrinfo_allow_skew, &tv,
            sizeof(struct timeval));
    } else if (str_matches_option(option, "max-timeouts:")) {
        const int maxtimeout = strtoint_clipped(val, 1, 255);
        if (maxtimeout == -1) return -1;
        if (!(flags & DNS_OPTION_MISC)) return 0;
        log(EVDNS_LOG_DEBUG, "Setting maximum allowed timeouts to %d",
            maxtimeout);
        base->global_max_nameserver_timeout = maxtimeout;
    } else if (str_matches_option(option, "max-inflight:")) {
        const int maxinflight = strtoint_clipped(val, 1, 65000);
        if (maxinflight == -1) return -1;
        if (!(flags & DNS_OPTION_MISC)) return 0;
        log(EVDNS_LOG_DEBUG, "Setting maximum inflight requests to %d",
            maxinflight);
        evdns_base_set_max_requests_inflight(base, maxinflight);
    } else if (str_matches_option(option, "attempts:")) {
        int retries = strtoint(val);
        if (retries == -1) return -1;
        if (retries > 255) retries = 255;
        if (!(flags & DNS_OPTION_MISC)) return 0;
        log(EVDNS_LOG_DEBUG, "Setting retries to %d", retries);
        base->global_max_retransmits = retries;
    } else if (str_matches_option(option, "randomize-case:")) {
        int randcase = strtoint(val);
        if (!(flags & DNS_OPTION_MISC)) return 0;
        base->global_randomize_case = randcase;
    } else if (str_matches_option(option, "bind-to:")) {
        /* XXX This only applies to successive nameservers, not
         * to already-configured ones.  We might want to fix that. */
        int len = sizeof(base->global_outgoing_address);
        if (!(flags & DNS_OPTION_NAMESERVERS)) return 0;
        if (evutil_parse_sockaddr_port(val,
            (struct sockaddr*)&base->global_outgoing_address, &len))
            return -1;
        base->global_outgoing_addrlen = len;
    } else if (str_matches_option(option, "initial-probe-timeout:")) {
        struct timeval tv;
        if (strtotimeval(val, &tv) == -1) return -1;
        if (tv.tv_sec > 3600)
            tv.tv_sec = 3600;
        if (!(flags & DNS_OPTION_MISC)) return 0;
        log(EVDNS_LOG_DEBUG, "Setting initial probe timeout to %s",
            val);
        memcpy(&base->global_nameserver_probe_initial_timeout, &tv,
            sizeof(tv));
    }
    return 0;
}

浏览过此段代码之后,我想心中肯定一目了然了.
那么问题的解决方法也就清楚了,只需要在evdns_base_new后加一行设置该选项为关闭就搞定. 如下:

    evdns_base_set_option(dnsbase, "randomize-case:", "0");//TurnOff DNS-0x20 encoding
    evdns_base_nameserver_ip_add(dnsbase, "180.76.76.76");//BaiduDNS
    evdns_base_nameserver_ip_add(dnsbase, "223.5.5.5");//AliDNS
    evdns_base_nameserver_ip_add(dnsbase, "223.6.6.6");//AliDNS
    evdns_base_nameserver_ip_add(dnsbase, "114.114.114.114");//114DNS
    evdns_base_nameserver_ip_add(dnsbase, "8.8.8.8");//GoogleDNS

写在最后:
开源项目就是这点方便,可以阅读其源码,有问题可以立马自己分析解决.
可惜的是开源氛围在长沙还是千里戈壁的景象.看来长沙的未来靠我戴维营哈!

标签:none

仅有 1 条评论

  1. Leland

    Thanks for your sharing! but are there such possibility to define some certain compatible DNS server like OpenDNS, without sacrificing the ability to defend against hijacking? BTW, are you a graduated CSU student too?
    Your docker website dive in edu seemed still under maintainance. EXPECTING.

添加新评论