linux内核DNS拦截程序及分析(设备域名绑定)-写法一

    技术2022-07-13  76

    前言

    我们经常有这么一个需求,我们可以通过ip地址来登录我们的设备。但是如果ip地址变化,我们又不知道设备的ip地址的时候,就不能够正常登录到设备,或者很难登录到设备(需要查看设备当前的ip地址才可以)。那么能不能实现我可以通过一个域名来登录设备(即:给设备绑定一个随意的域名,通过域名来访问设备)

    linux内核数据包桥流程分析

    这里引用网上比较好的博客,让大家对于linux桥接有一个直观的认识

    什么是桥接?

    简单来说,桥接就是把一台机器上的若干个网络接口“连接”起来。其结果是,其中一个网口收到的报文会被复制给其他网口并发送出去。以使得网口之间的报文能够互相转发。 交换机就是这样一个设备,它有若干个网口,并且这些网口是桥接起来的。于是,与交换机相连的若干主机就能够通过交换机的报文转发而互相通信。

    交换机在报文转发的过程中并不会篡改报文数据,只是做原样复制。然而桥接却并不是在物理层实现的,而是在数据链路层。交换机能够理解数据链路层的报文,所以实际上桥接却又不是单纯的报文转发。 交换机会关心填写在报文的数据链路层头部中的Mac地址信息(包括源地址和目的地址),以便了解每个Mac地址所代表的主机都在什么位置(与本交换机的哪个网口相连)。在报文转发时,交换机就只需要向特定的网口转发即可,从而避免不必要的网络交互。这个就是交换机的“地址学习”。但是如果交换机遇到一个自己未学习到的地址,就不会知道这个报文应该从哪个网口转发,则只好将报文转发给所有网口(接收报文的那个网口除外)。 比如主机C向主机A发送一个报文,报文来到了交换机S1的eth2网口上。假设S1刚刚启动,还没有学习到任何地址,则它会将报文转发给eth0和eth1。同时,S1会根据报文的源Mac地址,记录下“主机C是通过eth2网口接入的”。于是当主机A向C发送报文时,S1只需要将报文转发到eth2网口即可。而当主机D向C发送报文时,假设交换机S2将报文转发到了S1的eth2网口(实际上S2也多半会因为地址学习而不这么做),则S1会直接将报文丢弃而不做转发(因为主机C就是从eth2接入的)。

    然而,网络拓扑不可能是永不改变的。假设我们将主机B和主机C换个位置,当主机C发出报文时(不管发给谁),交换机S1的eth1口收到报文,于是交换机S1会更新其学习到的地址,将原来的“主机C是通过eth2网口接入的”改为“主机C是通过eth1网口接入的”。 但是如果主机C一直不发送报文呢?S1将一直认为“主机C是通过eth2网口接入的”,于是将其他主机发送给C的报文都从eth2转发出去,结果报文就发丢了。所以交换机的地址学习需要有超时策略。对于交换机S1来说,如果距离最后一次收到主机C的报文已经过去一定时间了(默认为5分钟),则S1需要忘记“主机C是通过eth2网口接入的”这件事情。这样一来,发往主机C的报文又会被转发到所有网口上去,而其中从eth1转发出去的报文将被主机C收到。

    linux桥接

    linux内核支持网口的桥接(目前只支持以太网接口)。但是与单纯的交换机不同,交换机只是一个二层设备,对于接收到的报文,要么转发、要么丢弃。小型的交换机里面只需要一块交换芯片即可,并不需要CPU。而运行着linux内核的机器本身就是一台主机,有可能就是网络报文的目的地。其收到的报文除了转发和丢弃,还可能被送到网络协议栈的上层(网络层),从而被自己消化。 linux内核是通过一个虚拟的网桥设备来实现桥接的。这个虚拟设备可以绑定若干个以太网接口设备,从而将它们桥接起来。

    数据包接收过程(如下图所示) 从图中我们可以看到桥虚拟接口br0下面挂载了两个接口eth0和wlan0。当数据包从eth0接口收到后数据包会被送到虚拟网桥接口br0。桥处的代码会判断是否采用二层转发还是3层转发。如果是二层转发,那么数据包就不会送到网络层,直接会在br0下挂的设备寻找匹配的接口,然后从该匹配的接口转发。如果数据包不满足二层转发条件,数据包就会被送到3层。3层代码又会判断是发送到本机还是需要本机转发,如果是发送到本机,则设备本身接收,如果需要路由转发,则查找路由表,然后转发。

    数据包发送流程 当本地发送的数据包或者网络转发的数据包会被送到br0。桥相关的代码会判断向挂载到br0下面的哪一个接口转发,将数据包从合适的接口转发出去。

    linux桥相关代码

    数据包流向

    接收数据包 netif_rx–>netif_rx_schedule–>net_rx_action–>process_backlog–>netif_receive_skb–>上层协议栈处理发送数据包fun_xmit–>dev_queue_xmit–>qdisc_run–>qdsic_restart–>net_tx_action–>dev->hard_start_xmit–>发送完成

    桥入口代码行数为int netif_receive_skb(struct sk_buff skb)* //网卡收到包会调用该函数将数据包向协议栈上扔 int netif_receive_skb(struct sk_buff skb) //网卡收到包会调用该函数将数据包向协议栈上扔,然后会调用br_handle_frame函数,让桥接的代码来处理这个报文。该函数就是桥的入口函数*

    static int __netif_receive_skb(struct sk_buff *skb) static int __netif_receive_skb_core(struct sk_buff *skb, bool pfmemalloc) //这里就会从rcu_dereference获取到rx_handler指向的函数指针,那么该函数又是在什么地方初始化的呢 rx_handler = rcu_dereference(skb->dev->rx_handler); if (rx_handler) { if (pt_prev) { ret = deliver_skb(skb, pt_prev, orig_dev); pt_prev = NULL; } switch (rx_handler(&skb)) //这里就是在调用实际的函数即:br_handle_frame

    那么这里产生了一个问题,br_handle_frame是什么时候注册到内核的呢。带着该问题我们来看一下代码。

    int br_add_if(struct net_bridge *br, struct net_device *dev) err = netdev_rx_handler_register(dev, br_handle_frame, p); int netdev_rx_handler_register(struct net_device *dev, rx_handler_func_t *rx_handler, void *rx_handler_data) { ASSERT_RTNL(); if (dev->rx_handler) return -EBUSY; /* Note: rx_handler_data must be set before rx_handler */ rcu_assign_pointer(dev->rx_handler_data, rx_handler_data); rcu_assign_pointer(dev->rx_handler, rx_handler); return 0; }

    我们可以看到,当调用底层程序br_add_if的时候,会调用netdev_rx_handler_register。netdev_rx_handler_register中初始化了dev->rx_handler处理程序。

    那么又产生了一个问题,什么时候会调用br_add_if函数呢? 在这里我们不妨来看看应用程序初始化桥和给桥添加接口的应用代码。 brtcl addbr br0 (建立一个网桥br0, 同时在Linux内核里面创建虚拟网卡br0) brtcl addif br0 eth0 brtcl addif br0 wlan0 通过这里我们可以看到给br0添加接口的时候就会调用操作系统底层的br_add_if。 我们自己的DNS拦截和修改代码就添加在br_handle_frame代码中

    dns拦截程序及分析

    正如上所说,我们的拦截代码是放在br_handle_frame中的,那么下面我们来看一下br_handle_frame的代码实现 // 下面的两句代码是我加的,因为这里没有hook点,所以自己 int (*g_fn_dnstrap)(struct sk_buff *skb) = NULL; EXPORT_SYMBOL(g_fn_dnstrap); rx_handler_result_t br_handle_frame(struct sk_buff **pskb) if (g_fn_dnstrap != NULL) { g_fn_dnstrap(skb); }

    在这里我们可以看到如果g_fn_dnstrap函数指针不为空,我们则调用该函数指针指向的函数。那么该函数指针什么时候被赋值呢?下面来看看我们的内核模块的代码。

    dns内核代码

    TODO:该内核模块非常重要,请仔细阅读

    DNS报文结构定义和分析

    DNS占用53号端口,同时使用TCP和UDP协议。那么DNS在什么情况下使用这两种协议? DNS在区域传输的时候使用TCP协议,其他时候使用UDP协议。 DNS区域传输的时候使用TCP协议:

    1.辅域名服务器会定时(一般3小时)向主域名服务器进行查询以便了解数据是否有变动。如有变动,会执行一次区域传送,进行数据同步。区域传送使用TCP而不是UDP,因为数据同步传送的数据量比一个请求应答的数据量要多得多。

    2.TCP是一种可靠连接,保证了数据的准确性。 域名解析时使用UDP协议:

    客户端向DNS服务器查询域名,一般返回的内容都不超过512字节,用UDP传输即可。不用经过三次握手,这样DNS服务器负载更低,响应更快。理论上说,客户端也可以指定向DNS服务器查询时用TCP,但事实上,很多DNS服务器进行配置的时候,仅支持UDP查询包。

    TODO: 该头部是DNS的头部信息,发送和接收该头部的长度是固定的 详情查看https://blog.csdn.net/liao152/article/details/45252387

    我们来看看dns头部信息我们是怎么定义的

    typedef struct _header { unsigned short int id; //id 位 unsigned short u; //标志位 short int qdcount; //问题数 short int ancount; //资源记录数 short int nscount; //授权资源记录数 short int arcount; //问题资源记录数 } dnsheader_t;

    对应着下图中的各字段。

    内核模块加载函数分析:

    #include "dns_packet.h" #include "dns_proc.h" typedef int (*fn_dnstrap)(struct sk_buff *skb); extern fn_dnstrap g_fn_dnstrap; static int __init dnstrap_init(void) { //创建proc文件 create_dnstrap_proc(); //我们可以看到这里将g_fn_dnstrap进行了赋值,所以重点函数为br_dns_trap_enter,我们将在后面对于该函数进行分析 g_fn_dnstrap = br_dns_trap_enter; return 0; } static void __exit dnstrap_eixt(void) { //销毁proc文件 destroy_dnstrap_proc(); g_fn_dnstrap = NULL; } MODULE_LICENSE("GPL"); module_init(dnstrap_init); module_exit(dnstrap_eixt);

    proc文件创建代码分析

    #include "dns_common.h" #define PROC_ROOT "dns_filter" #define PROC_DOMAIN_NAME "domain_name" #define PROC_ENABLE "enable" void create_dnstrap_proc(); void destroy_dnstrap_proc(); #include "dns_proc.h" bool g_dns_filter_enable = false; // 该全局变量从pro文件中获取用户输入的url域名 unsigned char g_domain_name[80] = {0}; static struct proc_dir_entry *dnstrap_proc_root = NULL; static int dnstrap_domain_read(struct seq_file *s, void *v) { seq_printf(s, "%s\n", g_domain_name); return 0; } static int dnstrap_domain_proc_open(struct inode *inode, struct file *file) { return (single_open(file, dnstrap_domain_read, NULL)); } static int dnstrap_domain_write(struct file *file, const char *buffer, unsigned long count, void *data) { if (count < 2) return -EFAULT; //从用户空间拷贝domain_name if (buffer && !copy_from_user(g_domain_name, buffer, 80)) { g_domain_name[count - 1] = 0; //转化为小写 str_to_lower(g_domain_name); return count; } return -EFAULT; } int dnstrap_domain_proc_write(struct file *file, const char __user *userbuf, size_t count, loff_t *off) { return dnstrap_domain_write(file, userbuf, count, off); } struct file_operations dnstrap_domain_proc_fops = { .open = dnstrap_domain_proc_open, .write = dnstrap_domain_proc_write, .read = seq_read, .llseek = seq_lseek, .release = single_release, }; //该函数是设置是否开启dns trap的功能 static int dnstrap_en_write(struct file *file, const char *buffer, unsigned long count, void *data) { char tmpbuf[80]; if (count < 2) return -EFAULT; if (buffer && !copy_from_user(tmpbuf, buffer, count)) { tmpbuf[count] = '\0'; if (tmpbuf[0] == '0') g_dns_filter_enable = true; else if (tmpbuf[0] == '1') g_dns_filter_enable = false; return count; } return -EFAULT; } static int dnstrap_en_read(struct seq_file *s, void *v) { seq_printf(s, "%d\n", g_dns_filter_enable); return 0; } static int dnstrap_en_proc_open(struct inode *inode, struct file *file) { return (single_open(file, dnstrap_en_read, NULL)); } static int dnstrap_en_proc_write(struct file *file, const char __user *userbuf, size_t count, loff_t *off) { return dnstrap_en_write(file, userbuf, count, off); } struct file_operations dnstrap_en_proc_fops = { .open = dnstrap_en_proc_open, .write = dnstrap_en_proc_write, .read = seq_read, .llseek = seq_lseek, .release = single_release, }; void create_dnstrap_proc() { dnstrap_proc_root = proc_mkdir(PROC_ROOT, NULL); if (dnstrap_proc_root) { proc_create_data(PROC_DOMAIN_NAME, 0, dnstrap_proc_root, &dnstrap_domain_proc_fops, NULL); proc_create_data(PROC_ENABLE, 0, dnstrap_proc_root, &dnstrap_en_proc_fops, NULL); } } void destroy_dnstrap_proc() { if (dnstrap_proc_root) { remove_proc_entry(PROC_DOMAIN_NAME, &dnstrap_proc_root); remove_proc_entry(PROC_ENABLE, &dnstrap_proc_root); remove_proc_entry(PROC_ROOT, NULL); } }

    DNS TRAP代码(非常关键的代码)

    DNS抓包图
    dns请求报文 dns应答报文截图 #include "dns_packet.h" extern unsigned char g_domain_name[80]; //我们可以从dns应答报文截图中可以看到我们访问的www.scdn.com返回了csdn的ip地址。该ip地址和域名在Answers字段中。同时可以抓取其他的数据包可以看到前12个字节是固定的,后4个字节对应的是域名所对应的ip地址。 static unsigned char dns_answer[] = {0xC0, 0x0C, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x04}; //该函数没有什么分析的,就是从dns请求报文中截取出访问的url地址。 static int get_domain_name(unsigned char *dns_body, char *domain_name, int body_len) { int offset = 0, token_len = 0; char token[64] = {0}; char domain[128] = {0}; short type; if (!dns_body || !domain_name || body_len <= 0) { return -1; } while (body_len > 0) { memset(token, 0, sizeof(token)); token_len = dns_body[offset]; if ((token_len > 0) && (token_len <= body_len)) { strncpy(token, dns_body + offset + 1, token_len); if (!domain[0]) { strncpy(domain, token, (sizeof(token) - 1)); } else { strncat(domain, ".", (sizeof(domain) - strlen(domain) - 1)); strncat(domain, token, (sizeof(domain) - strlen(domain) - 1)); } } else { if (token_len > body_len) printk("%s[%d], token_len is %d, body_len is %d\n", __FUNCTION__, __LINE__, token_len, body_len); break; } token_len += 1; body_len -= token_len; offset += token_len; } strncpy(domain_name, domain, (sizeof(domain) - 1)); return 0; } //判断是否是合法的dns请求头,关于dns的请求头部的每一个字段,可以查看上面贴出的dns报文信息的链接地址。这里其实可以少些判断,当然判断的越多越准确,主要是按照协议来的。 static bool is_valid_dns_query_header(dnsheader_t *dns_header) { if (dns_header == NULL) { return false; } if (dns_header->qdcount < 1) { return false; } if (((dns_header->u & 0x8000) >> 15) != 0) /*QR: query should be 0,answer be 1*/ { return false; } if (((dns_header->u & 0x7100) >> 11) != 0) /*opcode: 0:standard,1:reverse,2:server status*/ { return false; } if (((dns_header->u & 0x70) >> 4) != 0) /*Z: reserved, should be 0*/ { return false; } return true; } //该函数是判断我们通过应用层下发的proc中的domain_name和我们从dns报文中截取的是否相等 static bool is_domain_name_equal(char *domain_name1, char *domain_name2) { char temp1[128]; char temp2[128]; if (!domain_name1 || !domain_name2) { return false; } str_to_lower(domain_name1); str_to_lower(domain_name2); if (!strncmp(domain_name1, "www.", 4)) { strcpy(temp1, domain_name1 + 4); } else { strcpy(temp1, domain_name1); } if (!strncmp(domain_name2, "www.", 4)) { strcpy(temp2, domain_name2 + 4); } else { strcpy(temp2, domain_name2); } if (strcmp(temp1, temp2)) return false; else return true; } // TODO: 该函数是核心函数,设计到数据包的修改,该函数是dns报文的修改和重定向函数 int br_dns_packet_recap(struct sk_buff *skb) { struct iphdr *iph; struct udphdr *udph; struct net_device *br0_dev; struct in_device *br0_in_dev; dnsheader_t *dns_pkt; unsigned char mac[ETH_ALEN]; unsigned int ip; unsigned short port; unsigned char *ptr = NULL; int extend_len; extend_len = EXTEND_LEN_V4; /* 关于net_device和in_device详见https://blog.csdn.net/sinat_20184565/article/details/79898433 我们这里的需要返回br0当前的ip地址,所以需要获取用户给br0配置的ip地址,所以需要该函数 TODO: 用完该结构我们必须要调用put来进行释放 */ br0_dev = dev_get_by_name(&init_net, "br0"); br0_in_dev = in_dev_get(br0_dev); if (!br0_dev || !br0_in_dev) { if (br0_in_dev) in_dev_put(br0_in_dev); if (br0_dev) dev_put(br0_dev); return -1; } /* in_ifaddr表示地址结构,其成员包含了地址,掩码,范围等信息,多个地址连接成链表,主地址在前,从地址在后; struct in_ifaddr *ifa_list; /* IP ifaddr chain 如果br0接口配置的有ip地址,子网掩码等,所有的信息都存放在这里(我们可以从该结构中拿到当前的br0的ip地址) */ if (!br0_in_dev->ifa_list) { in_dev_put(br0_in_dev); dev_put(br0_dev); return -1; } iph = ip_hdr(skb); udph = (void *)iph + iph->ihl * 4; dns_pkt = (void *)udph + sizeof(struct udphdr); // ptr为尾部指针,指向了dns请求报文的尾部 ptr = (void *)udph + ntohs(udph->len); /* 将数据段向后扩大extend_len的长度(这里是16个字节,ip地址为4个字节,固定长度12个字节) 该条语句的操作可以参考wireshark抓包。 TODO:下面这句话是该整个修改数据包函数的核心思想 TODO:dns的回复报文需要在请求报文(queries字段)的后面添加应答字段(answers),具体可以查看 上面的博客连接和通过抓包分析 answers字段中的前12个字段是固定的,12个字段后面接的是ip地址 所以是16个字节 */ skb_put(skb, extend_len); // TODO:下面的操作其实都是在操作五元组,内核中数据包的流向都是靠五元组(源) /* 源IP地址、目的IP地址、协议号、源端口、目的端口(协议号已经是有的了) */ //交换mac地址 /* 在网络通信的是否,数据包中的地址变化过程如下:(不在同一局域网) 源mac地址为当前设备的mac地址,目的mac是下一条设备的mac地址 目的ip不变,源ip地址是为当前设备的ip地址。 在这里的理解为: 当pc访问www.baidu.com的时候,首先会向网关发送dns请求。(此时的源mac地址为pc的mac,目的mac地址为网关。源ip地址为pc的ip地址,目的ip地址为网关的ip地址) 当我们在网关拦截到数据包的时候,需要交换数据报文中的mac地址和ip地址。 */ memcpy(mac, eth_hdr(skb)->h_dest, ETH_ALEN); memcpy(eth_hdr(skb)->h_dest, eth_hdr(skb)->h_source, ETH_ALEN); memcpy(eth_hdr(skb)->h_source, mac, ETH_ALEN); //交换ip地址 ip = iph->saddr; iph->saddr = iph->daddr; iph->daddr = ip; //重新计算ip头部信心中的tot_len,tot_len代表ip数据包的长度 iph->tot_len = htons(ntohs(iph->tot_len) + extend_len); /* 交换udp端口 */ port = udph->source; udph->source = udph->dest; udph->dest = port; //计算udp头部的长度 udph->len = htons(ntohs(udph->len) + extend_len); //下面是根据wireshark抓包得到的 dns_pkt->u = htons(0x8180); dns_pkt->qdcount = htons(1); dns_pkt->ancount = htons(1); dns_pkt->nscount = htons(0); dns_pkt->arcount = htons(0); //填充12位固定的数据,在dns应答报文的截图中可以看到 memcpy(ptr, dns_answer, 12); //将br0的ip地址填充到skb中 memcpy(ptr + 12, (unsigned char *)&br0_in_dev->ifa_list->ifa_address, 4); /* ip checksum */ // TODO: CHECKSUM_NONE表示发送侧已经计算了校验和,协议栈将不会再计算校验和 skb->ip_summed = CHECKSUM_NONE; //计算ip头部校验和,在计算之前应该先赋值为0 iph->check = 0; iph->check = ip_fast_csum((unsigned char *)iph, iph->ihl); /* udp checksum */ udph->check = 0; udph->check = csum_tcpudp_magic(iph->saddr, iph->daddr, ntohs(udph->len), IPPROTO_UDP, csum_partial((char *)udph, ntohs(udph->len), 0)); if (br0_in_dev) { in_dev_put(br0_in_dev); } if (br0_dev) { dev_put(br0_dev); } return 1; } /* 该函数只会判断ipv4的报文,ipv6的报文这里不会涉及 */ int br_dns_trap_enter(struct sk_buff *skb) { struct iphdr *iph; struct udphdr *udph; iph = (struct iphdr *)skb_network_header(skb); //判断是否为ipv4的报文 if (iph->version != 4) { return -1; } //获取udp头部 udph = (void *)iph + iph->ihl * 4; // DNS报文是属于udp数据包,同时端口号为53 if (iph->protocol != IPPROTO_UDP || ntohs(udph->dest) != 53) { return -1; } //获取DNS报文中的domain_name的长度 /* 说明:udp数据包头部信息中的len字段是udp头部信息和数据信息的总长度 domain_name_len的长度和我们在应用层看到的domain_name的长度不一样,比如应用层我们看到的www.baidu.com他的长度为12个字节。 但是在数据包中一般为14个字节,因为他会指明域名的长度(具体看wireshark抓包截图) ntohs(udph->len) - sizeof(struct udphdr) - sizeof(dnsheader_t)代表的是dns请求报文中的Queries字段的长度。该字段也是放在报文的最后面(我们请求的域名就包含在该字段中) 那么这里为什么还要减去4呢,因Queries中有4个字节是固定的,分别为Type和Class字段,他们一共占据4个字节。减去4之后剩下的,才是我们真正的domain_name_len的长度 */ int domain_name_len = ntohs(udph->len) - sizeof(struct udphdr) - sizeof(dnsheader_t) - 4; if (domain_name_len <= 1) { return -1; } // 获取dns头部信息 dnsheader_t *dns_hdr = (dnsheader_t *)((void *)udph + sizeof(struct udphdr)); //判断dns头部是否有效 if (is_valid_dns_query_header(dns_hdr) != true) { return -1; } //获取dns中的数据包 unsigned char *body = (void *)udph + sizeof(struct udphdr) + sizeof(dnsheader_t); //获取dns数据包中的domain_len(这里是按照wireshark中来解析的) unsigned char domain_name[128] = {0}; if (get_domain_name(body, domain_name, domain_name_len) != 0) { return -1; } //判断报文中获取到的domain_name和应用层传入的domain_name是否相等 if (is_domain_name_equal(domain_name, g_domain_name) == false) { return -1; } //上面的匹配都满足了,证明该数据包我们需要处理,这里处理的核心思想就是改变原有数据包,然后发送。 br_dns_packet_recap(skb); }

    代码地址:代码仓库地址

    未完待续,该代码我会在后续加入ipv6的截图和分析以及劫持。上面的代码已经测试过,能够跳转。这次的代码只是DNS Trap中redirect的一种写法,我会在后面的博客中提供多种redirect写法以及filter写法。欢迎关注博客,欢迎加入QQ群610849576交流

    Processed: 0.020, SQL: 9