前言
最近在做网络包分析的一个功能,其中有一点就是要判断抓包期间应用是否有存在异常的DNS 解析行为,并且要写在一个使用Java 语言的后端服务中,由于之前使用Go 语言做过类似的事情,所以我第一反应就是这个应该实现起来比较简单,但是实际上手后,发现在Java 中要拿到DNS 报文还没那么容易,当然也有可能是我没有找到正确的工具,如果有方便的工具,欢迎在评论区留言。那么本文就结合我如何从网络包中拿到DNS报文,进行一个简单介绍。
正文
一. DNS报文格式简析
DNS 报文本质是UDP 报文,UDP报文的格式如下所示。
而DNS 相关内容,就体现在UDP 报文载荷中。DNS 报文分为DNS 请求报文和DNS 响应报文,两种报文结构一样,只是内容稍有差别,如下是DNS报文的格式。
DNS报文内容包含如下三个部分。
第一部分是基础结构部分,解释如下。
- Transaction ID 。DNS 报文的事务ID 标识,DNS 请求报文和DNS 响应报文的Transaction ID 是一样的,故可以通过这个ID 关联DNS请求和响应报文;
- Flags 。DNS 报文的标志,标志由若干个含义不同的字段组成,较为常用的是第0位可以表示当前是请求DNS 报文还是响应DNS 报文,第12-15位可以表示响应DNS 报文的Reply Code;
- Questions 。问题数,表示后面Queries的数量;
- Answer RRs 。回答资源记录数,表示后面Answers的数量;
- Authority RRs 。授权资源记录数,表示后面Authoritative nameservers的数量;
- Additional RRs 。附加资源记录数,表示后面Additional records的数量。
第二部分是问题部分,对应Queries ,该部分表示DNS 查询请求的问题信息,包含查询的域名Name ,查询的类型Type 和查询的类Class,解释如下。
- Name 。就是请求DNS 解析的域名地址,这里的Name是一个不定长的字段,格式示意如下。
- Type 。表示DNS 查询的资源类型,Name 字段结束后的两个字节就是Type ,通常关注较多的有0x0001 ,助记符是A ,表示通过域名查询IPv4 地址,以及0x001C ,助记符是AAAA ,表示通过域名查询IPv6地址;
- Class 。表示地址类型,通常为0x0001,表示互联网地址。
第三部分是资源记录部分,解释如下。
- Answers。记录域名解析出来的地址信息;
- Authoritative nameservers。记录解析该域名对应的权威名称服务器信息;
- Additional records。记录解析该域名对应的一些附加信息。
二. DNS报文解析实现
先回顾一下需求,就是需要得到应用是否存在异常的DNS 解析,那么结合上面的DNS报文格式,我们的判断逻辑可以像下面这样。
- 先找到只有请求DNS 报文但没有响应DNS 报文的DNS 解析,这是一种异常的DNS 解析情况,即DNS服务器没有响应解析请求;
- 拿到响应DNS 报文的Reply Code ,然后根据Reply Code 得到异常的DNS 解析情况,Reply Code的枚举如下所示。
Reply Code | 说明 |
---|---|
0 | 正常 |
1 | 报文格式错误 |
2 | 域名服务器异常 |
3 | 域名不存在 |
4 | 解析类型Type不支持 |
5 | 域名服务器拒绝请求 |
- 计算DNS解析的请求和响应报文的时间差,得到解析耗时过长的异常情况。
那么其实我们需要的DNS报文的内容就很明确了,如下所示。
-
Transaction ID ,事务ID ;
-
Reply Code ,响应码;
-
Name ,域名;
-
Type,解析类型。
现在就开始本文的正题,如何使用Java 语言来解析网络包并得到DNS 报文。在Go 语言中,可以使用google/gopacket 来方便的拿到DNS 报文,但是在Java 中,要一步拿到DNS报文尚有点困难,但是可以基于如下步骤来操作。
- 基于io.pkts 的工具包来解析网络包并拿到UDP报文;
- 过滤出源或目标端口号为53的UDP 报文,这是因为通常DNS服务器会工作在53号端口上;
- 拿到过滤后的UDP 报文的载荷,按照DNS 报文的格式解析得到Transaction ID ,Reply Code ,Name 和Type。
现在进行实操。先引入io.pkts的依赖,如下所示。
xml
<dependency>
<groupId>io.pkts</groupId>
<artifactId>pkts-streams</artifactId>
<version>3.0.10</version>
</dependency>
<dependency>
<groupId>io.pkts</groupId>
<artifactId>pkts-core</artifactId>
<version>3.0.10</version>
</dependency>
然后基于Spring 的MultipartFile 来上传网络包并使用io.pkts 工具解析出UDP报文,实现如下所示。
java
@RestController
public class FileUpload {
@PostMapping("/upload/udp")
public void uploadUdp(MultipartFile uploadFile) throws IOException {
try (InputStream inputStream = uploadFile.getInputStream()) {
GZIPInputStream gzipInputStream = new GZIPInputStream(inputStream);
Pcap pcap = Pcap.openStream(gzipInputStream);
pcap.loop(packet -> {
if (packet.hasProtocol(Protocol.UDP)) {
UDPPacket udpPacket = (UDPPacket) packet.getPacket(Protocol.UDP);
if (udpPacket.getSourcePort() == 53
|| udpPacket.getDestinationPort() == 53) {
final byte[] payloadByteArray = udpPacket.getPayload().getArray();
// 在这里根据DNS报文格式解析数据
......
}
}
return true;
});
}
}
}
解析Transaction ID的逻辑如下所示。
java
private String parseTransactionId(byte[] array) {
// 前两字节是Transaction ID,表示会话标识
// DNS请求报文和响应报文通过Transaction ID进行匹配
return HexUtils.toHexString(Arrays.copyOfRange(array, 0, 2));
}
解析请求或响应报文类型的逻辑如下所示。
java
private int parseDnsPackageType(byte[] array) {
// 第3和第4字节是Flags,表示标志
// Flags的第0位代表请求或响应的类型
// 0表示DNS请求报文,1表示DNS响应报文
String binaryString = String.format("%08d", Integer.parseInt(Integer.toBinaryString(array[2] & 0xFF)));
return Integer.parseInt(binaryString.substring(0, 1), 2);
}
解析Reply Code的逻辑如下所示。
java
private int parseDnsRcode(byte[] array) {
// 第3和第4字节是Flags,表示标志
// Flags的最后四位表示Reply Code
String binaryString = String.format("%08d", Integer.parseInt(Integer.toBinaryString(array[3] & 0xFF)));
return Integer.parseInt(binaryString.substring(4, 8), 2);
}
解析Type的逻辑如下所示。
java
private int parseDnsQueryType(byte[] array) {
// 第13字节开始,是域名,域名以0x00结尾
// 域名结束后的两个字节就代表DNS查询类型
int domainEndIndex = -1;
for (int i = 12; i < array.length; i++) {
if ((array[i] & 0xFF) == 0) {
domainEndIndex = i;
break;
}
}
String s = HexUtils.toHexString(Arrays.copyOfRange(array, domainEndIndex + 1, domainEndIndex + 3));
return Integer.parseInt(s, 16);
}
解析Name的逻辑如下所示。
java
private String parseDnsQueryDomain(byte[] array) {
// 从第13字节开始,遵循[域名长度][域名][域名长度][域名]...0x00的规律
// 故按照上述规律,从第13字节开始,将域名的所有组成部分获取出来并拼接
List<String> domainParts = new ArrayList<>();
int lengthIndex = 12;
do {
int partLength = array[lengthIndex] & 0xFF;
String s = new String(Arrays.copyOfRange(array, lengthIndex + 1, lengthIndex + partLength + 2)).trim();
domainParts.add(s);
lengthIndex = lengthIndex + partLength + 1;
} while ((array[lengthIndex] & 0xFF) != 0);
return String.join(".", domainParts);
}
那么至此我们期望得到的DNS 报文的数据,我们就拿到了,后续就是将这些数据组装为一个Entity来方便我们在程序中使用和处理,这里就不再演示了。
总结
我使用Java 语言从网络包中解析出DNS报文的步骤总结如下。
- 使用io.pkts 解开网络包并过滤得到UDP报文;
- 过滤出源或目标端口号为53的UDP报文;
- 拿到过滤后的UDP 报文的载荷,按照DNS 报文的格式解析得到想要的DNS数据。