在本博客的DNS协议详解及报文格式分析一文中介绍了DNS的基本理论,DNS协议的报文格式等,如果详细了解了的话,不免会萌生出自己实现DNS协议的想法。要知道DNS协议是基于UDP的,如果能够自己组装出一个合法有效的DNS报文,便可以通过socket将DNS查询报文发出去,并能得到相应的域名服务器的响应报文,对响应报文进行解析,便可以得到最终的IP地址。本文基于此,介绍了实现DNS协议的思路,给出了完整的可运行代码。废话不多说,马上开始。
1. 协议头部结构定义
首先需要根据 DNS协议详解及报文格式分析 一文的介绍,将DNS的头部数据结构构造出来。DNS头部实际上只有三个部分的内容:会话标识(2字节),标志(2字节)和数量字段(共8字节),这个头部是最终的发送或是接收报文的头部,所以采用的是网络字节序。下面给出代码。
#pragma pack(push, 1)
struct DNSHeader
{
/* 1. 会话标识(2字节)*/
unsigned short usTransID; // Transaction ID
/* 2. 标志(共2字节)*/
unsigned char RD : 1; // 表示期望递归,1bit
unsigned char TC : 1; // 表示可截断的,1bit
unsigned char AA : 1; // 表示授权回答,1bit
unsigned char opcode : 4; // 0表示标准查询,1表示反向查询,2表示服务器状态请求,4bit
unsigned char QR : 1; // 查询/响应标志位,0为查询,1为响应,1bit
unsigned char rcode : 4; // 表示返回码,4bit
unsigned char zero : 3; // 必须为0,3bit
unsigned char RA : 1; // 表示可用递归,1bit
/* 3. 数量字段(共8字节) */
unsigned short Questions; // 问题数
unsigned short AnswerRRs; // 回答资源记录数
unsigned short AuthorityRRs; // 授权资源记录数
unsigned short AdditionalRRs; // 附加资源记录数
};
#pragma pack(pop)
上述代码有几个地方需要注意一下:
#pragma pack(push, 1)和#pragma pack(pop)。使结构体按1字节方式对齐,其中push表示把原来的对齐方式压栈,pop表示恢复原来的对齐方式。- 像
usTransID、Questions、AnswerRRs...这些两个字节的字段,由于是网络字节序,所以在给这些字段填充内容时,需要使用htons函数做转换,后面的报文组装代码里有写到。 - 如果仔细对比会发现标志字段的书写顺序比较怪异。比如标志字段的第一个字节,在DNS报文中顺序应该是
<QR-opcode-AA-TC-RD>(注:按照报文中内容的顺序,QR是低位,RD是高位),而上面的代码中顺序是<RD-TC-AA-OPCODE-QR>,这是因为我们定义各个位时使用了C/C++中的位域语法。位域中将高位放在了前面,将低位放在了后面,比如:1011 0010B,如果用下面的BitFieldDemo所示的位域结构表示的话,则a == 10B, b == 110B, c == 010B。所以,<RD-TC-AA-OPCODE-QR>这样的定义其实表明RD是高位,QR是低位,正好符合了DNS的头部标志字段要求。标志字段的第二个字节类似。
struct BitFieldDemo
{ // 假如有二进制数1011 0010B,左边为高位,右边为低位
// 则a == 10B, b == 110B, c == 010B
unsigned char a : 2; // 高2位
unsigned char b : 3;
unsigned char c : 3; // 低2位
};
2. 查询报文组装与发送
万事开头难,头部数据结构定义好了之后,后面就好办多了,无非就是将标志以及需要查询的内容(主要是域名)填充到头部和正文的Queries字段,然后使用socket发出去即可。完整代码见下面SendDnsPack所示,本节给出的查询报文组装与发送实现代码中,是以A类型(0x1)为例的,A类型表示由域名查询获得IPv4地址。
主要分为以下两个大的步骤:
- 根据第1节定义的DNS报文头部,组装查询报文
- 使用
sendto函数将报文发送到DNS服务器的53号端口
// @Brief : 发送DNS查询报文
// @Param: usID: 报文ID编号
// pSocket: 需要发送的socket
// szDnsServer: DNS服务器地址
// szDomainName: 需要查询的域名
// @Retrun: true表示发送成功,false表示发送失败
bool SendDnsPack(IN unsigned short usID,
IN SOCKET *pSocket,
IN const char *szDnsServer,
IN const char *szDomainName)
{
bool bRet = false;
if (*pSocket == INVALID_SOCKET
|| szDomainName == NULL
|| szDnsServer == NULL
|| strlen(szDomainName) == 0
|| strlen(szDnsServer) == 0)
{
return bRet;
}
unsigned int uiDnLen = strlen(szDomainName);
// 判断域名合法性,域名的首字母不能是点号,域名的
// 最后不能有两个连续的点号
if ('.' == szDomainName[0] || ( '.' == szDomainName[uiDnLen - 1]
&& '.' == szDomainName[uiDnLen - 2])
)
{
return bRet;
}
/* 1. 将域名转换为符合查询报文的格式 */
// 查询报文的格式是类似这样的:
// 6 j o c e n t 2 m e 0
unsigned int uiQueryNameLen = 0;
BYTE *pbQueryDomainName = (BYTE *)malloc(uiDnLen + 1 + 1);
if (pbQueryDomainName == NULL)
{
return bRet;
}
// 转换后的查询字段长度为域名长度 +2
memset(pbQueryDomainName, 0, uiDnLen + 1 + 1);
// 下面的循环作用如下:
// 如果域名为 jocent.me ,则转换成了 6 j o c e n t ,还有一部分没有复制
// 如果域名为 jocent.me.,则转换成了 6 j o c e n t 2 m e
unsigned int uiPos = 0;
unsigned int i = 0;
for ( i = 0; i < uiDnLen; ++i)
{
if (szDomainName[i] == '.')
{
pbQueryDomainName[uiPos] = i - uiPos;
if (pbQueryDomainName[uiPos] > 0)
{
memcpy(pbQueryDomainName + uiPos + 1, szDomainName + uiPos, i - uiPos);
}
uiPos = i + 1;
}
}
// 如果域名的最后不是点号,那么上面的循环只转换了一部分
// 下面的代码继续转换剩余的部分, 比如 2 m e
if (szDomainName[i-1] != '.')
{
pbQueryDomainName[uiPos] = i - uiPos;
memcpy(pbQueryDomainName + uiPos + 1, szDomainName + uiPos, i - uiPos);
uiQueryNameLen = uiDnLen + 1 + 1;
}
else
{
uiQueryNameLen = uiDnLen + 1;
}
// 填充内容 头部 + name + type + class
DNSHeader *PDNSPackage = (DNSHeader*)malloc(sizeof(DNSHeader) + uiQueryNameLen + 4);
if (PDNSPackage == NULL)
{
goto exit;
}
memset(PDNSPackage, 0, sizeof(DNSHeader) + uiQueryNameLen + 4);
// 填充头部内容
PDNSPackage->usTransID = htons(usID); // ID
PDNSPackage->RD = 0x1; // 表示期望递归
PDNSPackage->Questions = htons(0x1); // 本文第一节所示,这里用htons做了转换
// 填充正文内容 name + type + class
BYTE* PText = (BYTE*)PDNSPackage + sizeof(DNSHeader);
memcpy(PText, pbQueryDomainName, uiQueryNameLen);
unsigned short *usQueryType = (unsigned short *)(PText + uiQueryNameLen);
*usQueryType = htons(0x1); // TYPE: A
++usQueryType;
*usQueryType = htons(0x1); // CLASS: IN
// 需要发送到的DNS服务器的地址
sockaddr_in dnsServAddr = {};
dnsServAddr.sin_family = AF_INET;
dnsServAddr.sin_port = ::htons(53); // DNS服务端的端口号为53
dnsServAddr.sin_addr.S_un.S_addr = ::inet_addr(szDnsServer);
// 将查询报文发送出去
int nRet = ::sendto(*pSocket,
(char*)PDNSPackage,
sizeof(DNSHeader) + uiQueryNameLen + 4,
0,
(sockaddr*)&dnsServAddr,
sizeof(dnsServAddr));
if (SOCKET_ERROR == nRet)
{
printf("DNSPackage Send Fail! \n");
goto exit;
}
// printf("DNSPackage Send Success! \n");
bRet = true;
// 统一的资源清理处
exit:
if (PDNSPackage)
{
free(PDNSPackage);
PDNSPackage = NULL;
}
if (pbQueryDomainName)
{
free(pbQueryDomainName);
pbQueryDomainName = NULL;
}
return bRet;
}
代码中有几个地方需要注意一下:
- 关于域名合法性的判断,域名的开头不能有点号,但是域名的结尾是允许有一个点号的,结尾的点号其实表示的是根域名服务器,在本博客 DNS协议详解及报文格式分析 这篇文章有讲到
- 因为最终发出的报文中的
Queries字段中,查询的名字格式是类似6 j o c e n t 2 m e 0这样的,所以有一部分代码是做这个转换的 - Type, Class,Questions 都是两个字节,为了转换成网路字节序,需要使用 htons 函数
3. 响应报文接收与解析
当成功的向DNS服务端发出查询报文后,接下来就是等待响应报文,DNS响应报文的格式与查询报文相比,头部标志字段QR由0变成了1,正文部分多了些字段,比如Answers字段等。下文中的RecvDnsPack即是响应报文接收与解析的代码。
主要分为以下两个大的步骤:
- 使用
recvfrom函数接收服务端返回的内容 - 解析收到的内容,主要是从中获取IP地址。解析的过程中首先对收到内容的合法性做了一些校验,分别校验了内容长度、ID号、QR值等;然后使用指针遍历的方式依次解析响应报文中的内容
代码中注释已经相当详细,不再赘述。
void RecvDnsPack(IN unsigned short usId,
IN SOCKET *pSocket )
{
if (*pSocket == INVALID_SOCKET)
{
return;
}
char szBuffer[256] = {}; // 保存接收到的内容
sockaddr_in servAddr = {};
int iFromLen = sizeof(sockaddr_in);
int iRet = ::recvfrom(*pSocket,
szBuffer,
256,
0,
(sockaddr*)&servAddr,
&iFromLen);
if (SOCKET_ERROR == iRet || 0 == iRet)
{
printf("recv fail \n");
return;
}
/* 解析收到的内容 */
DNSHeader *PDNSPackageRecv = (DNSHeader *)szBuffer;
unsigned int uiTotal = iRet; // 总字节数
unsigned int uiSurplus = iRet; // 接受到的总的字节数
// 确定收到的szBuffer的长度大于sizeof(DNSHeader)
if (uiTotal <= sizeof(DNSHeader))
{
printf("接收到的内容长度不合法\n");
return;
}
// 确认PDNSPackageRecv中的ID是否与发送报文中的是一致的
if (htons(usId) != PDNSPackageRecv->usTransID)
{
printf("接收到的报文ID与查询报文不相符\n");
return;
}
// 确认PDNSPackageRecv中的Flags确实为DNS的响应报文
if ( 0x01 != PDNSPackageRecv->QR )
{
printf("接收到的报文不是响应报文\n");
return;
}
// 获取Queries中的type和class字段
unsigned char *pChQueries = (unsigned char *)PDNSPackageRecv + sizeof(DNSHeader);
uiSurplus -= sizeof(DNSHeader);
for ( ; *pChQueries && uiSurplus > 0; ++pChQueries, --uiSurplus ) { ; } // 跳过Queries中的name字段
++pChQueries;
--uiSurplus;
if ( uiSurplus < 4 )
{
printf("接收到的内容长度不合法\n");
return;
}
unsigned short usQueryType = ntohs( *((unsigned short*)pChQueries) );
pChQueries += 2;
uiSurplus -= 2;
unsigned short usQueryClass = ntohs( *((unsigned short*)pChQueries) );
pChQueries += 2;
uiSurplus -= 2;
// 解析Answers字段
unsigned char *pChAnswers = pChQueries;
while (0 < uiSurplus && uiSurplus <= uiTotal)
{
// 跳过name字段(无用)
if ( *pChAnswers == 0xC0 ) // 存放的是指针
{
if (uiSurplus < 2)
{
printf("接收到的内容长度不合法\n");
return;
}
pChAnswers += 2; // 跳过指针字段
uiSurplus -= 2;
}
else // 存放的是域名
{
// 跳过域名,因为已经校验了ID,域名就不用了
for ( ; *pChAnswers && uiSurplus > 0; ++pChAnswers, --uiSurplus ) {;}
pChAnswers++;
uiSurplus--;
}
if (uiSurplus < 4)
{
printf("接收到的内容长度不合法\n");
return;
}
unsigned short usAnswerType = ntohs( *((unsigned short*)pChAnswers) );
pChAnswers += 2;
uiSurplus -= 2;
unsigned short usAnswerClass = ntohs( *( (unsigned short*)pChAnswers ) );
pChAnswers += 2;
uiSurplus -= 2;
if ( usAnswerType != usQueryType || usAnswerClass != usQueryClass )
{
printf("接收到的内容Type和Class与发送报文不一致\n");
return;
}
pChAnswers += 4; // 跳过Time to live字段,对于DNS Client来说,这个字段无用
uiSurplus -= 4;
if ( htons(0x04) != *(unsigned short*)pChAnswers )
{
uiSurplus -= 2; // 跳过data length字段
uiSurplus -= ntohs( *(unsigned short*)pChAnswers ); // 跳过真正的length
pChAnswers += 2;
pChAnswers += ntohs( *(unsigned short*)pChAnswers );
}
else
{
if (uiSurplus < 6)
{
printf("接收到的内容长度不合法\n");
return;
}
uiSurplus -= 6;
// Type为A, Class为IN
if ( usAnswerType == 1 && usAnswerClass == 1)
{
pChAnswers += 2;
unsigned int uiIP = *(unsigned int*)pChAnswers;
in_addr in = {};
in.S_un.S_addr = uiIP;
printf("IP: %s\n", inet_ntoa(in));
pChAnswers += 4;
}
else
{
pChAnswers += 6;
}
}
}
}
4. 测试
本小节给出上述发送函数与接受函数的测试代码,测试的过程中可以用Wireshark抓包看下发包和收包的情况,能够加深理解。测试程序运行结果如右图所示: 
int main( int argc, char* argv[])
{
WSADATA wsaData = {};
if ( 0 != ::WSAStartup(MAKEWORD(2, 2), &wsaData) )
{
printf("WSAStartup fail \n");
return -1;
}
SOCKET socket = ::socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP);
if (INVALID_SOCKET == socket)
{
printf("socket fail \n");
return -1;
}
int nNetTimeout = 2000;
// 设置发送时限
::setsockopt(socket, SOL_SOCKET, SO_SNDTIMEO, (char *)&nNetTimeout, sizeof(int));
// 设置接收时限
::setsockopt(socket, SOL_SOCKET, SO_RCVTIMEO, (char *)&nNetTimeout,sizeof(int));
// 随机生成一个ID
srand((unsigned int)time(NULL));
unsigned short usId = (unsigned short)rand();
// 自定义需要查询的域名
char szDomainName[256] = {};
printf("输入要查询的域名:");
scanf("%s", szDomainName);
// 发送DNS报文,因为测试,这里就简单指定8.8.8.8作为查询服务器
if (!SendDnsPack(usId, &socket, "8.8.8.8", szDomainName))
{
return -1;
}
// 接收响应报文,并显示获得的IP地址
RecvDnsPack(usId, &socket);
closesocket(socket);
WSACleanup();
return 0;
}
P.S. 本文代码所用的编译链接环境是Windows下的VC编译环境,如果在Linux下编译可能需要对代码做略微调整,但整体结构应该是一样的,知悉。
转载请注明:爱开源 » 自己动手实现DNS协议