本节,我们基于上一节理论的基础上,用代码实现DNS数据包的发送和解析。这里有两点需要重复,一是我们将使用DNS的递归式传输模式,也就是消息的发送如下图:
也就是我们将在数据包中的特定数据段内设置标志位,要求第一台域名解析服务器帮我们实现所有的查询流程,然后把最终结果返回给我们,这样我们可以省却多种数据交互和解析流程,一般而言第一台域名解析服务器都是路由器。
第二个值得我们了解的要点是DNS数据包的基本格式:
它包括固定的头部,以及相应的消息体部分。由于头部内容固定不变,因此我们可以在代码实现中写死,它的基本组成结构如下:
重要的是有两个可变的数据部分需要我们掌握,一个是Question数据格式,它包含了客户端向服务器请求的内容格式,它的组成如下:
当我们想要解析某个域名对应的IP时,我们需要按照上面的结构组织信息发布给服务器,服务器顺利解读后会给我们发送如下格式的应答信息:
由此我们代码的目的是构造包头,然后将要查询的域名信息按照上面给出的Question数据格式组织好发送给路由器并等待其回复,拿到回复数据包之后,我们再按照上头anwser resource格式解析服务器返回的数据。
接下来让我们看看代码实现:
package Application;
import java.nio.ByteBuffer;
import java.util.Random;
public class DNSApplication extends Application {
private byte[] resove_server_ip = null;
private String domainName = "";
private byte[] dnsHeader = null;
private int transition_id = 0;
public DNSApplication( byte[] destIP, String domainName) {
this.resove_server_ip = destIP;
this.domainName = domainName;
Random rand = new Random();
transition_id = rand.nextInt();
constructDNSPacketHeader();
}
private void constructDNSPacketHeader() {
/*
* 构造DNS数据包包头,总共12字节
*/
byte[] header = new byte[12];
ByteBuffer buffer = ByteBuffer.wrap(header);
//2字节的会话id
buffer.putShort((short)transition_id);
//接下来是2字节的操作码,不同的比特位有相应含义
short opCode = 0;
/*
* 如果是查询数据包,第0个比特位要将最低位设置为0,接下来的4个比特位表示查询类型,如果是查询ip则设置为0,
* 第5个比特位由服务器在回复数据包中设置,用于表明信息是它拥有的还是从其他服务器查询而来,
* 第6个比特位表示消息是否有分割,有的话设置为1,由于我们使用UDP,因此消息不会有分割。
* 第7个比特位表示是否使用递归式查询请求,我们设置成1表示使用递归式查询,
* 第8个比特位由服务器返回时设置,表示它是否接受递归式查询
* 第9,10,11,3个比特位必须保留为0,
* 最后四个比特由服务器回复数据包设置,0表示正常返回数据,1表示请求数据格式错误,2表示服务器出问题,3表示不存在给定域名等等
* 我们发送数据包时只要将第7个比特位设置成1即可
*/
opCode = (short) (opCode | (1 << 7));
buffer.putShort(opCode);
//接下来是2字节的question count,由于我们只有1个请求,因此它设置成1
short questionCount = 1;
buffer.putShort(questionCount);
//剩下的默认设置成0
short answerRRCount = 0;
buffer.putShort(answerRRCount);
short authorityRRCount = 0;
buffer.putShort(authorityRRCount);
short additionalRRCount = 0;
buffer.putShort(additionalRRCount);
this.dnsHeader = buffer.array();
}
}
上面代码中,函数constructDNSPacketHeader完成了查询数据包头部数据的组装,接下来我们我们实现Question数据部分的组装:
private void constructDNSPacketQuestion() {
/*
* 构造DNS数据包中包含域名的查询数据结构
* 首先是要查询的域名,它的结构是是:字符个数+是对应字符,
* 例如域名字符串pan.baidu.com对应的内容为
* 3pan[5]baidu[3]com也就是把‘.'换成它后面跟着的字母个数
*/
//根据.将域名分割成多个部分,第一个1用于记录"pan"的长度,第二个1用0表示字符串结束
dnsQuestion = new byte[1 + 1 + domainName.length() + QUESTION_TYPE_LENGTH + QUESTION_CLASS_LENGTH];
String[] domainParts = domainName.split("\\.");
ByteBuffer buffer = ByteBuffer.wrap(dnsQuestion);
for (int i = 0; i < domainParts.length; i++) {
//先填写字符个数
buffer.put((byte)domainParts[i].length());
//填写字符
for(int k = 0; k < domainParts[i].length(); k++) {
buffer.put((byte) domainParts[i].charAt(k));
}
}
//表示域名字符串结束
byte end = 0;
buffer.put(end);
//填写查询问题的类型和级别
buffer.putShort(QUESTION_TYPE_A);
buffer.putShort(QUESTION_CLASS);
}
上面代码根据我们前面描述的Question数据结构,将要查询的域名字符串封装起来发送给服务器进行解析。完成两部分关键数据的组装后,我们就可以将其组合成一个完整的DNS数据包发送出去:
public void queryDomain() {
//向服务器发送域名查询请求数据包
byte[] dnsPacketBuffer = new byte[dnsHeader.length + dnsQuestion.length];
ByteBuffer buffer = ByteBuffer.wrap(dnsPacketBuffer);
buffer.put(dnsHeader);
buffer.put(dnsQuestion);
byte[] udpHeader = createUDPHeader(dnsPacketBuffer);
byte[] ipHeader = createIP4Header(udpHeader.length);
byte[] dnsPacket = new byte[udpHeader.length + ipHeader.length];
buffer = ByteBuffer.wrap(dnsPacket);
buffer.put(ipHeader);
buffer.put(udpHeader);
//将消息发送给路由器
try {
ProtocolManager.getInstance().sendData(dnsPacket, resove_server_ip);
} catch (Exception e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
}
我们在前面章节中已经多次展示过UDP包头和IP包头的组装,在这里我们不再将其代码罗列出来,当上面代码完成后执行时,我们通过wireshark监控可以发现,程序顺利构造了对应的DNS数据包发送,同时受到了服务器的回复数据包:
接下来我们要做的是解析服务器回复的数据包,解析代码如下:
public void handleData(HashMap headerInfo) {
/*
* 解读服务器回发的数据包,首先读取头2字节判断transition_id是否与我们发送时使用的一致
*/
byte[] data = (byte[])headerInfo.get("data");
if (data == null) {
System.out.println("empty data");
return;
}
ByteBuffer buffer = ByteBuffer.wrap(data);
short transionID = buffer.getShort();
if (transionID != this.transition_id) {
System.out.println("transition id different!");
return;
}
//读取2字节flag各个比特位的含义
short flag = buffer.getShort();
readFlags(flag);
//接下来2字节表示请求的数量
short questionCount = buffer.getShort();
System.out.println("client send " + questionCount + " requests");
//接下来的2字节表示服务器回复信息的数量
short answerCount = buffer.getShort();
System.out.println("server return " + answerCount + " answers");
//接下来2字节表示数据拥有属性信息的数量
short authorityCount = buffer.getShort();
System.out.println("server return " + authorityCount + " authority resources");
//接下来2字节表示附加信息的数量
short additionalInfoCount = buffer.getShort();
System.out.println("serve return " + additionalInfoCount + " additional infos");
//回复数据包会将请求数据原封不动的复制,所以接下来我们先处理question数据结构
readQuestions(questionCount, buffer);
//读取服务器回复信息
readAnswers(answerCount, buffer);
}
一旦接收到服务器回发的数据包,上面函数就会被调用,首先它解析包头,看看会话id是否与我们发出数据包的id相匹配,然后读取余下内容。由于服务器回复的数据中包含了请求数据包发送的Question数据部分,因此我们也进行相应解读:
private void readQuestions(int count, ByteBuffer buffer) {
for (int i = 0; i < count; i++) {
readStringContent(buffer);
//查询问题的类型
short questionType = buffer.getShort();
if (questionType == QUESTION_TYPE_A) {
System.out.println("request ip for given domain name");
}
//查询问题的级别
short questionClass = buffer.getShort();
System.out.println("the class of the request is " + questionClass);
}
}
private void readStringContent(ByteBuffer buffer) {
byte charCnt = buffer.get();
while(charCnt > 0) {
//输出字符
for (int i = 0; i < charCnt; i++) {
System.out.print((char)buffer.get());
}
charCnt = buffer.get();
if (charCnt != 0) {
System.out.print(".");
}
}
System.out.println("\n");
}
这里需要注意的是解析域名字符串的格式,它是[数字][字符]格式,因此代码读取时首先获得字符的个数,然后再读取相应字符。接下来我们看看读取Answer Resource Record的代码实现,该数据结构的解析稍微复杂一些:
private void readAnswers(int count, ByteBuffer buffer) {
/*
* 回复信息的格式如下:
* 第一个字段是name,它的格式如同请求数据中的域名字符串
* 第二个字段是类型,2字节
* 第三字段是级别,2字节
* 第4个字段是Time to live, 4字节,表示该信息可以缓存多久
* 第5个字段是数据内容长度,2字节
* 第6个字段是内如数组,长度如同第5个字段所示
*/
/*
* 在读取第name字段时,要注意它是否使用了压缩方式,如果是那么该字段的第一个字节就一定大于等于192,也就是
* 它会把第一个字节的最高2比特设置成11,接下来的1字节表示数据在dns数据段中的偏移
*/
for (int i = 0; i < count; i++) {
System.out.println("Name content in answer filed is: ");
if (isNameCompression(buffer.get())) {
int offset = (int)buffer.get();
byte[] array = buffer.array();
ByteBuffer dup_buffer = ByteBuffer.wrap(array);
//从指定偏移处读取字符串内容
dup_buffer.position(offset);
readStringContent(dup_buffer);
} else {
readStringContent(buffer);
}
short type = buffer.getShort();
System.out.println("answer type is : " + type);
//接下来2字节对应type
if (type == DNS_ANSWER_CANONICAL_NAME_FOR_ALIAS) {
System.out.println("this answer contains server string name");
}
//接下来2字节是级别
short cls = buffer.getShort();
System.out.println("answer class: " + cls);
//接下来4字节是time to live
int ttl = buffer.getInt();
System.out.println("this information can cache " + ttl + " seconds");
//接下来2字节表示数据长度
short rdLength = buffer.getShort();
System.out.println("content length is " + rdLength);
if (type == DNS_ANSWER_CANONICAL_NAME_FOR_ALIAS) {
readStringContent(buffer);
}
if (type == DNS_ANSWER_HOST_ADDRESS) {
//显示服务器返回的IP
byte[] ip = new byte[4];
for (int k = 0; k < 4; k++) {
ip[k] = buffer.get();
}
try {
InetAddress ipAddr = InetAddress.getByAddress(ip);
System.out.println("ip address for domain name is: " + ipAddr.getHostAddress());
} catch (UnknownHostException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
} //if (type == DNS_ANSWER_HOST_ADDRESS)
} //for (int i = 0; i < count; i++)
}
private boolean isNameCompression(byte b) {
if ((b & (1<<7)) != 0 && (b & (1<<6)) != 0) {
return true;
}
return false;
}
private void readFlags(short flag) {
//最高字节为1表示该数据包为回复数据包
if ((flag & (1 << 15))!= 0) {
System.out.println("this is packet return from server");
}
//如果第9个比特位为1表示客户端请求递归式查询
if ((flag & (1 << 8)) != 0) {
System.out.println("client requests recursive query!");
}
//第8个比特位为1表示服务器接受递归式查询请求
if ((flag & (1 << 7)) != 0) {
System.out.println("server accept recursive query request!");
}
//第6个比特位表示服务器是否拥有解析信息
if ((flag & (1 << 5)) != 0) {
System.out.println("sever own the domain info");
} else {
System.out.println("server query domain info from other servers");
}
}
这里需要特别注意的一点是,在服务器返回的应答数据中,它会对字符串进行压缩,我们看下图:
我在上头选择字符串pan.baidu.com,但下面只对应两个字节。这是因为回复的数据包为了节省内容长度,如果字符串在数据包的前面出现过,那么它就不会再把相同的数据重复一遍。它会用两个字节表示重复信息,第一个字节的最高两个比特设置成11,表示当前字符串使用压缩表示法,根据上一节描述,当解析数据包字符串时,我们首先读取的是字符个数,如果不采用压缩表示,那么字符个数不允许超过63,因此该字节的头两位绝不可能是11.
如果字节头两位是11,那么我们就确认数据包使用了字符串压缩法。第二个字节告诉我们字符串所在位置相对偏移。例如上图中第二个字节0c表示从DNS数据开始偏移12个字节就是本处要显示的字符串。所以在函数readAnswer的实现中,在读取字符串时,如果发现采用压缩方法,那么它就读取第二个字节获得偏移,然后从数据段起始处偏移相应字节后再进行读取。
更详细的讲解和代码调试演示过程,请点击链接
更多技术信息,包括操作系统,编译器,面试算法,机器学习,人工智能,请关照我的公众号: