本文总结一下自己写原生DNS客户端的过程。

0x01 前言

由于之前一直使用Linuxldns这个库,但是那么多方法,真的看起来很心累,刚好一直在学习socket编程,准备从底层实现这个客户端。

0x02 DNS介绍

DNS(Domain Name System)是一个提供域名解析服务的分布式系统,主要功能是完成域名与其对应的IP地址之间的相互转换。网络中提供DNS服务的主机叫做DNS服务器,而向DNS服务器发起查询请求的主机叫做DNS客户端,客户端与服务器之间通过DNS协议这一种应用层协议来相互通信并交换数据。

DNS协议建立在UDP或TCP协议之上,DNS服务器开放UDP:53端口和TCP:53端口监听客户端发来的请求。一般情况下客户端通过UDP协议封装请求报文,服务器也用UDP协议封装回应报文;由于广域网中不适合传输过大的UDP数据包,因此规定当封装了DNS回应的UDP数据包长度可能超过512字节时,客户端应该使用TCP协议连接DNS服务器并传输请求和回应,具体包括以下两种情况:

  • 客户端认为UDP回应包长度可能超过512字节,主动使用TCP协议;
  • 客户端第一次使用UDP协议发送DNS请求,服务端发现UDP回应包会超过512字节,截断UDP包中的回应报文,并在回应报文中为TC字段置1以通知客户端该报文已经被截断,客户端收到之后再发起一次TCP请求。

0x03 DNS报文格式

简单的铺垫就到这里,先来记录一下我遇到的坑; 首先我是去看了DNS的报文格式,然后又参考了网上的一些文章,发现标志位有反有正,导致我写了一个查询A记录的请求一直报错。

DNS数据报由头部和记录部分组成,其中请求报文只有问题部分,而回应报文可以有问题部分、回答部分、授权部分和附加部分。

可以参考:RFC1035文档来获悉

简单介绍一些基础:

报文格式


    +---------------------+
    |        Header       | 报文头
    +---------------------+
    |       Question      | 查询的问题
    +---------------------+
    |        Answer       | 应答
    +---------------------+
    |      Authority      | 授权应答
    +---------------------+
    |      Additional     | 附加信息
    +---------------------+
  • Header段是必须存在的,它定义了报文是请求还是应答,也定义了其他段是否需要存在,以及是标准查询还是其他。
  • Question段描述了查询的问题,包括查询类型(QTYPE)查询类(QCLASS),以及查询的域名(QNAME)
  • 剩下的3个段包含相同的格式:一系列可能为空的资源记录(RRs)。
  • Answer段包含回答问题的RRs;授权段包含授权域名服务器的RRs;
  • 附加段包含和请求相关的,但是不是必须回答的RRs。

今天研究的只是报文查询问题

报文头包含如下字段:


    +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
    |                      ID                       |
    +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
    |QR|   Opcode |AA|TC|RD|RA|   Z    |   RCODE    |
    +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
    |                    QDCOUNT                    |
    +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
    |                    ANCOUNT                    |
    +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
    |                    NSCOUNT                    |
    +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
    |                    ARCOUNT                    |
    +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+

C语言中我们可以依次从上到下,从左到右的方式进行填充。

Header各字段分别解释如下:

  • ID 请求客户端设置的16位标示,服务器给出应答的时候会带相同的标示字段回来,这样请求客户端就可以区分不同的请求应答了。
  • QR 1个比特位用来区分是请求(0)还是应答(1)。
  • OPCODE 4个比特位用来设置查询的种类,应答的时候会带相同值,可用的值如下:

0               标准查询 (QUERY)
1               反向查询 (IQUERY)
2               服务器状态查询 (STATUS)
3-15            保留值,暂时未使用
  • AA 授权应答(Authoritative Answer) - 这个比特位在应答的时候才有意义,指出给出应答的服务器是查询域名的授权解析服务器。注意:因为别名的存在,应答可能存在多个主域名,这个AA位对应请求名,或者应答中的第一个主域名。
  • TC 截断(TrunCation) - 用来指出报文比允许的长度还要长,导致被截断。
  • RD 期望递归(Recursion Desired) - 这个比特位被请求设置,应答的时候使用的相同的值返回。如果设置了RD,就建议域名服务器进行递归解析,递归查询的支持是可选的。
  • RA 支持递归(Recursion Available) - 这个比特位在应答中设置或取消,用来代表服务器是否支持递归查询。
  • Z 保留值,暂时未使用。在所有的请求和应答报文中必须置为0。
  • RCODE 应答码(Response code) - 这4个比特位在应答报文中设置,代表的含义如下:

0               没有错误。
1               报文格式错误(Format error) - 服务器不能理解请求的报文。
2               服务器失败(Server failure) - 因为服务器的原因导致没办法处理这个请求。
3               名字错误(Name Error) - 只有对授权域名解析服务器有意义,指出解析的域名不存在。
4               没有实现(Not Implemented) - 域名服务器不支持查询类型。
5               拒绝(Refused) - 服务器由于设置的策略拒绝给出应答。比如,服务器不希望对某些请求者给出应答,或者服务器不希望进行某些操作(比如区域传送zone transfer)。
6-15            保留值,暂时未使用。
  • QDCOUNT 无符号16位整数表示报文请求段中的问题记录数。
  • ANCOUNT 无符号16位整数表示报文回答段中的回答记录数。
  • NSCOUNT 无符号16位整数表示报文授权段中的授权记录数。
  • ARCOUNT 无符号16位整数表示报文附加段中的附加记录数。

Question的字段格式

在大多数查询中,Question段包含着问题(question),比如,指定问什么。这个段包含QDCOUNT(usually 1)个问题

每个问题为下面的格式:


+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
|                                               |
|                     QNAME                     |
|                                               |
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
|                     QTYPE                     |
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
|                     QCLASS                    |
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+

字段含义如下:

  • QNAME 域名被编码为一些labels序列,每个labels包含一个字节表示后续字符串长度,以及这个字符串,以0长度和空字符串来表示域名结束。注意这个字段可能为奇数字节,不需要进行边界填充对齐
  • QTYPE 2个字节表示查询类型,.取值可以为任何可用的类型值,以及通配码来表示所有的资源记录。
  • QCLASS 2个字节表示查询的协议类,比如,IN代表Internet

0x04 编程思路

我们一般会先申请一段连续的内存空间,然后采用结构体进行赋值,最后拼装起来

char buff[1024];

网上大部分是先根据报文格式定义Header结构体:

struct DNS_HEADER
{
 unsigned short id; // identification number

 unsigned char rd :1; // recursion desired
 unsigned char tc :1; // truncated message
 unsigned char aa :1; // authoritive answer
 unsigned char opcode :4; // purpose of message
 unsigned char qr :1; // query/response flag

 unsigned char rcode :4; // response code
 unsigned char cd :1; // checking disabled
 unsigned char ad :1; // authenticated data
 unsigned char z :1; // its z! reserved
 unsigned char ra :1; // recursion available

 unsigned short q_count; // number of question entries
 unsigned short ans_count; // number of answer entries
 unsigned short auth_count; // number of authority entries
 unsigned short add_count; // number of resource entries
};

struct QUESTION
{
 unsigned short qtype;
 unsigned short qclass;
};

typedef struct
{
 unsigned char *name;
 struct QUESTION *ques;
} QUERY

然后通过指针进行指向每一段数据报文格式的起始位置:

Dnsquery = (struct QUESTION*) &buf[sizeof(struct DNS_HEADER) + (strlen((const char*) qname) + 1)];

不断的对这个buff进行分割,最后通过简单的UDP套接字传输过去:


int dnsSock = socket(AF_INET,SOCK_DGRAM,0);
    struct sockaddr_in dnsADDR;
    dnsADDR.sin_family = AF_INET;
    dnsADDR.sin_addr.s_addr = inet_addr("202.101.172.35");
    dnsADDR.sin_port = htons(53);
    if (sendto(dnsSock,buff, buff_length, 0, (struct sockaddr*) &dnsADDR, sizeof(dnsADDR)) < 0) {
       std::cout <<  "sendto failed" << std::endl;
    }
close(dnsSock);

0x05 遇到的坑

一开始当然是很粗心的看错了报文的定义大小,然后尝试定义了一些结构体,最后发送的数据与正确的数据包不一样:

失败的数据包

  • 该数据包的标志ID为:0x17c7

0000   00 1c 42 00 00 18 00 1c 42 af 22 19 08 00 45 00  ..B.....B."...E.
0010   00 4b 7b 22 40 00 40 11 07 16 0a d3 37 0e ca 65  .K{"@.@.....7..e
0020   ac 23 b6 51 00 35 00 37 c9 33 17 c7 01 00 00 01  .#.Q.5.7.3......
0030   00 00 00 00 00 00 63 6f 6e 6e 65 63 74 69 76 69  ......connectivi
0040   74 79 2d 63 68 65 63 6b 06 75 62 75 6e 74 75 03  ty-check.ubuntu.
0050   63 6f 6d 00 01 00 01 00 00                       com......

正确请求的数据包

  • 该数据包的标志ID为:0xb05c

0000   00 1c 42 00 00 18 00 1c 42 af 22 19 86 dd 60 07  ..B.....B."...`.
0010   46 ce 00 42 11 40 fe 80 00 00 00 00 00 00 2b 48  F..B.@........+H
0020   8e 47 77 1b 19 96 fe 80 00 00 00 00 00 00 02 1c  .Gw.............
0030   42 ff fe 00 00 18 ce 95 00 35 00 42 4a f4 b0 5c  B........5.BJ..\
0040   01 00 00 01 00 00 00 00 00 01 12 63 6f 6e 6e 65  ...........conne
0050   63 74 69 76 69 74 79 2d 63 68 65 63 6b 06 75 62  ctivity-check.ub
0060   75 6e 74 75 03 63 6f 6d 00 00 01 00 01 00 00 29  untu.com.......)
0070   02 00 00 00 00 00 00 00                          ........

第一眼看区别,肯定是一大一小,是不是数据缺失了?

前面交代了标志ID,它是一个数据包的真正开始位置,一直到最后一个标识结束

那么我们从失败的和成功的标志位开始读取:


17 c7 01 00 00 01
00 00 00 00 00 00 63 6f 6e 6e 65 63 74 69 76 69
74 79 2d 63 68 65 63 6b 06 75 62 75 6e 74 75 03
63 6f 6d 00 01 00 01 00 00

b0 5c
01 00 00 01 00 00 00 00 00 01 12 63 6f 6e 6e 65
63 74 69 76 69 74 79 2d 63 68 65 63 6b 06 75 62
75 6e 74 75 03 63 6f 6d 00 00 01 00 01 00 00 29
02 00 00 00 00 00 00 00

0x630x6d后面的一个 0x00 都是 connectivity-check.ubuntu.com对应Question的字段中的QNAME

去除以后,关键就在标志位:


17 c7 
01 00 00 01
00 00 00 00 00 00 01 00 01 00 00

b0 5c
01 00 00 01 00 00 00 00 00 01 12 00 01 00 01 00 00 29
02 00 00 00 00 00 00 00

以成功报文的来看:

  • 前面的0xb05cHeader标识(ID)字段
  • 向后两个字节就是 0x010x00代表 0x0100,代表了标志位,分别说明QR\OPCODE\TC\RD\Z\RCODE

注意:OPCODE是四个比特位。


0... .... .... => QR
.000 0... .... => OPCODE
.... ..0. .... => TC
.... ...1 .... => RD
.... .... .0.. => Z
.... .... ..0. => RCODE

以上简写为:0x0100

后来在Wireshark中发现了两个数据报文的不同:

标志位

标志位完全错乱了。

0x06 我的DNS请求

在理解好前面的知识以后,我们来动手写一个高大上的DNS请求程序:



#include <iostream>
#include <arpa/inet.h>
#include <sys/socket.h>

int main(int argc,char * argv[]) {
char buff[101] ="\x65\xa5" \
                    "\x01\x00\x00\x01\x00\x00\x00\x00\x00\x01\x12\x63\x6f\x6e\x6e\x65" \
                    "\x63\x74\x69\x76\x69\x74\x79\x2d\x63\x68\x65\x63\x6b\x06\x75\x62" \
                    "\x75\x6e\x74\x75\x03\x63\x6f\x6d\x00\x00\x1c\x00\x01\x00\x00\x29" \
                    "\x02\x00\x00\x00\x00\x00\x00\x00";
    int dnsSock = socket(AF_INET,SOCK_DGRAM,0);
    struct sockaddr_in dnsADDR;
    dnsADDR.sin_family = AF_INET;
    dnsADDR.sin_addr.s_addr = inet_addr("202.101.172.35");
    dnsADDR.sin_port = htons(53);
    if (sendto(dnsSock,buff, 58, 0, (struct sockaddr*) &dnsADDR, sizeof(dnsADDR)) < 0) {
        std::cout <<  "sendto failed" << std::endl;
    }
    close(dnsSock);
}

上面的代码会向DNS服务器发送一个查询connectivity-check.ubuntu.com A 记录的DNS请求。

我们来分析一下这个buff code

标志ID:0x65a5



0000   00 1c 42 00 00 18 00 1c 42 af 22 19 08 00 45 00  ..B.....B."...E.
0010   00 56 23 b3 40 00 40 11 5e 7a 0a d3 37 0e ca 65  .V#.@.@.^z..7..e
0020   ac 23 c9 30 00 35 00 42 52 1d 65 a5 01 00 00 01  .#.0.5.BR.e.....
0030   00 00 00 00 00 01 12 63 6f 6e 6e 65 63 74 69 76  .......connectiv
0040   69 74 79 2d 63 68 65 63 6b 06 75 62 75 6e 74 75  ity-check.ubuntu
0050   03 63 6f 6d 00 00 1c 00 01 00 00 29 02 00 00 00  .com.......)....
0060   00 00 00 00                                      ....

0x65a5进行剥离,我们得到如下数据包:


65 a5 01 00 00 01
00 00 00 00 00 01 12 63 6f 6e 6e 65 63 74 69 76
69 74 79 2d 63 68 65 63 6b 06 75 62 75 6e 74 75
03 63 6f 6d 00 00 1c 00 01 00 00 29 02 00 00 00
00 00 00 00

取出域名Name范围:


65 a5 
01 00 00 01
00 00 00 00 00 01 12 00 1c 00 01 00 00 29 02 00 
00 00
00 00 00 00

这样看数据包就很清晰了~

0x07 关于UDP服务DDOS拒绝服务的思考

发送大量的DNS递归查询会消耗服务端的一定资源,我们只需要设置一个RD标志位就可以让目标服务器进行不断的递归查询

发送垃圾查询,例如:查询 www.baidu.com\n 这类的域名,是肯定找不到的,并且查询很慢

重放UDP能够带来一定的安全隐患,我如果拥有足够大的权限,完全可以针对某个协议进行中间人攻击

0x08 最后的总结

学习了这么久的网络技术,最后发现就协议规范编程来说并不是很难,难就难在没有下耐心去研究,去思考。

整个过程需要具备以下技能:

  • Wireshark的灵活使用
  • 网络协议基础
  • C/C++功底

一个疑问

我上面的buff code操作能够重放UDP协议数据包,重放过后我发现请求报文中的标志位无法被Wireshark解析了,并且数据传送完毕还会附带一个ICMP的请求。

结束

以后有其他相关的东西再更新吧…… 先写到这里。