使用tun/tap将数据包导入协议栈

Posted by Alex King on October 22, 2014

我原本以为寝室的电脑ping不通实验室的电脑是因为之间隔了一层NAT的关系,昨天听吴博说了才知道原来没有NAT,而是防火墙的关系。防火墙应该是丢弃了ICMP包和所有入站的TCP SYN包,所以外面的电脑无法通过TCP直接连接实验室电脑。跟吴博一番讨论后,萌发了尝试突破寝室电脑无法TCP连接到实验室电脑的限制。

大致上的思路是通过某种渠道将TCP SYN数据包发到实验室电脑上,然后将数据包导入本机协议栈。这个渠道可以是一个第三方服务器,分别和实验室电脑和寝室电脑连接,作为一个中转站。因为我们测试后发现实验室防火墙没有封锁UDP数据包,所以直接用UDP来转发TCP SYN数据包。

问题的关键在于将应用层获得的数据包导入到本机协议栈,让实验室电脑的协议栈响应TCP SYN ACK握手包,这个包不会被防火墙拦截,而且防火墙在看到这个包之后会建立连接信息,之后的数据包就能顺利通过防火墙了。之前正好了解了一下OpenVPN关于tun/tap的实现原理,觉得非常相似,所以决定尝试一下。

有关tun/tap的资料,可以阅读IBM的这篇文章,讲的还是挺不错的。就我个人的理解,在进程创建了一个tun/tap设备后,如果将路由表中的某一项路由到该虚拟设备后,当有数据包满足该路由条目后,内核就会将该数据包写入到tun/tap设备,相当于tun/tap的发送过程,而此时读取该tun/tap设备(tun/tap也实现了字节设备驱动)的进程就能获得这个要发送的数据包,此时可以对数据包进行处理后再通过其他渠道发送出去,OpenVPN就是对数据包进行加密后,再用UDP发送出去。而当进程向tun/tap设备写入时,就是tun/tap设备的接收过程,写入的数据会被tun/tap设备的虚拟网卡驱动作为正常的数据包放入协议栈处理。这正是我需要的功能。

环境

还是先介绍一下整个应用的环境,客户机是位于寝室的电脑,Windows 7 x64,使用WinPcap抓包,编译器是Visual C++ 2013(VC130),IP地址10.100.248.83。服务器是位于实验室的电脑,Ubuntu 14.04(幸好是Linux,Windows的话虽然肯定也能做但我是不知道了),IP地址10.10.90.192,监听UDP端口10000。

先搞定客户机

客户机这边比较好搞定,所以先来介绍客户机的工作。主要的任务是用WinPcap抓取发往服务器的TCP SYN数据包,然后用UDP封装后发出去。过滤数据包的代码如下:

pcap_compile(fp, 
    &fcode, 
    "dst host 10.10.90.192 and tcp[tcpflags] & (tcp-syn) != 0",
    0,
    netmask);

抓到数据包后的发送代码如下,因为做的是实验性的程序,所以就不要纠结性能和代码是不是优美了。

void packet_handler(u_char * user, 
                    const struct pcap_pkthdr * header,
                    const u_char * data)
{
	SOCKET sockClient = socket(AF_INET, SOCK_DGRAM, 0);
	SOCKADDR_IN addrServ;
	addrServ.sin_addr.S_un.S_addr = inet_addr("10.10.90.192");
	addrServ.sin_family = AF_INET;
	addrServ.sin_port = htons(10000);
	/* 这里+14和-14是当服务器使用tun时的数据,因为tun不需要mac层
	 * 所以去掉,后面用tap的时候就可以去掉了
	 */
	sendto(sockClient, (const char *)(data + 14), 
	        header->caplen - 14, 0, 
	        (SOCKADDR*)&addrServ, sizeof(SOCKADDR));
}

服务器的TUN尝试

服务器这边有点复杂,我首先想到的是用tun做,因为tun工作在第三层,逻辑上简单一些。当然好多代码其实是与tap通用的,打开tun/tap设备的代码是从上面那篇IBM的文档中模仿的。

首先是打开tun/tap设备的代码,主要是通过dev名字区分是tap还是tun,内核通过IFF_TUNIFF_TAP标志来区分:

int tun_creat(const char * dev, char * actual, int size)
{
    struct ifreq ifr;
    int fd, err;
    if ((fd = open("/dev/net/tun", O_RDWR)) < 0)
        return -1;
    memset(&ifr, 0, sizeof(ifr));
    ifr.ifr_flags = IFF_NO_PI;
    if (!strncmp(dev, "tun", 3)) {
        ifr.ifr_flags |= IFF_TUN;
    } else if (!strncmp(dev, "tap", 3)) {
        ifr.ifr_flags |= IFF_TAP;
    } else {
        fprintf(stderr, "Device %s illegal\n", dev);
        close(fd);
        return -1;
    }
    if (strlen(dev) > 3)
        strncpy(ifr.ifr_name, dev, IFNAMSIZ);

    if ((err = ioctl(fd, TUNSETIFF, (void *)&ifr)) < 0) {
        fprintf(stderr, "Cannot ioctl TUNSETIFF %s\n", dev);
        close(fd);
        return err;
    }
    strncpy(actual, ifr.ifr_name, size);
    return fd;
}

创建完成后要将设备启动起来,相当于在shell中执行ip link set xxx up,通过ioctl设置标志来实现:

int dev_bring_up(const char * dev)
{
    int sockfd;
    struct ifreq ifr;
    if ((sockfd = socket(AF_INET, SOCK_DGRAM, 0)) < 0)
        return -1;
        
    memset(&ifr, 0, sizeof ifr);
    strncpy(ifr.ifr_name, dev, IFNAMSIZ);
    //先获取再设置
    if (ioctl(sockfd, SIOCGIFFLAGS, &ifr) < 0) {
        fprintf(stderr, "Error up %s\n", dev);
        return -1;
    }
    ifr.ifr_ifru.ifru_flags |= IFF_UP;
    if (ioctl(sockfd, SIOCSIFFLAGS, &ifr) < 0) {
        fprintf(stderr, "Error up %s\n", dev);
        return -1;
    }
    close(sockfd);
    return 0;
}

通过调用这两个函数来启动一个tun设备:

    int tun;
    char tun_name[IFNAMSIZ];
    tun = tun_creat("tap10", tun_name, sizeof(tun_name));
    dev_bring_up(tun_name);

创建后启动tun设备后就可以监听UDP端口,每次读到一个数据报就将数据全部写入到tun设备中:

    int sockfd;
    struct sockaddr_in servaddr, cliaddr;
    sockfd = socket(AF_INET, SOCK_DGRAM, 0);
    bzero(&servaddr, sizeof(servaddr));
    servaddr.sin_family = AF_INET;
    servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
    servaddr.sin_port = htons(10000);
    bind(sockfd, (struct sockaddr *)&servaddr, sizeof(servaddr));
    while (1) {
        char pkt[1500];
        int clilen = sizeof(cliaddr);
        int n = recvfrom(sockfd, pkt, sizeof(pkt), 0, 
                (struct sockaddr *)&cliaddr, &clilen);
        calc_checksum(pkt, n);
        /* 下面这行在后面用tap的时候需要去掉注释。
         * mac是一个6字节的char数组,应该在之前申明。
         */
        //memcpy(pkt, mac, 6);
        write(tun, pkt, n);
    }

这里的calc_checksum函数是用来计算IP校验和和TCP校验和的,因为客户机很有可能开启了各种offload功能,将校验和的计算工作交给了网卡做处理,所以WinPcap抓到的数据包是没有经过校验和计算的,我们需要自己计算也确保协议栈接收数据包。

到此程序大致已经成型,运行后,通过Wireshark抓包发现tun虚拟设备已经有接收到的数据了,但是协议栈并没有按照预期的响应TCP SYN ACK数据包。其中的原理我也不是很明白,不过估计是因为10.10.90.192这个IP是设置在eth0网卡上的,所以虽然这个IP地址确实是本机网卡地址,但因为与接收的网卡不同所以协议栈不接收。

使用tap

既然tun不行,那只能考虑tap,使用tap的好处是tap会虚拟出一块完整的以太网卡,而Linux内核支持虚拟网桥设备,如果把eth0和虚拟出来的tap都挂接到虚拟出的网桥br0上,把10.10.90.192这个IP分配给网桥br0,就可以让tap接收的数据包转发到br0上从而实现了目的IP与实际IP相同,这样协议栈就应该会接收这个数据包了。

首先要创建网桥,在shell中使用brctl命令:

brctl addbr br0                 #创建网桥
brctl addif br0 eth0            #将eth0先加入网桥
ifconfig br0 10.10.90.192 up    #启动网桥并分配IP
ifconfig eth0 0.0.0.0           #eth0现在不需要IP地址了

然后在代码中,创建并启动完tap10后,将它加入到网桥中,这里偷个懒直接用system函数了:

    system("brctl addif br0 tap10");

调整所有代码中的IP首部的偏移量。最后一个要解决的问题是要在把数据包写入到tap之前,修改以太网首部的目的MAC地址为网桥的MAC地址,不然目的MAC地址与网桥MAC地址不一致,网桥仍然不会接受这个包。获取网络设备MAC地址的方法是用SIOCGIFHWADDR调用ioctl

/* mac应该是一个至少6字节的buffer */
int dev_get_mac(const char * dev, char * mac)
{
    int sockfd;
    struct ifreq ifr;
    if ((sockfd = socket(AF_INET, SOCK_DGRAM, 0)) < 0)
        return -1;

    memset(&ifr, 0, sizeof ifr);
    strncpy(ifr.ifr_name, dev, IFNAMSIZ);
    if (ioctl(sockfd, SIOCGIFHWADDR, &ifr) < 0) {
        fprintf(stderr, "Error get mac %s\n", dev);
        return -1;
    }
    memcpy(mac, &ifr.ifr_hwaddr.sa_data, 6);
    close(sockfd);
    return 0;
}

这次编译运行后,结果就如同我预期的那样,寝室电脑与服务器电脑已经可以顺利建立起TCP连接了,整个实验宣告成功。

最后说一下几个使用虚拟网桥的问题,在创建了br0之后,好多直接挂在eth0上的设置要变为br0,例如原来的默认路由表项是:

default via 10.10.90.1 dev eth0

现在要修改为:

default via 10.10.90.1 dev br0

原本我使用OpenVPN做nat时的设置是:

iptables -t nat -A POSTROUTING -s 10.8.0.0/24 -o eth0 -j MASQUERADE

现在要修改为:

iptables -t nat -A POSTROUTING -s 10.8.0.0/24 -o br0 -j MASQUERADE

因为原本的eth0已经没有地址了,MASQUERADE的结果就变成空的了,也就不会做nat了。