0x00 前言

最近学了点 eBPF 相关的东西,写了 github-hosts-ebpf 这个项目练手, 项目基于 aya-rs 开发,功能是通过 XDP 解析 DNS 响应包,当发现 DNS 解析 GITHUB 相关域名时,修改 DNS 响应包中的 A 记录为加速 IP 地址。你可能要问了,为什么不直接改 hosts 文件?emmm, just for fun :)

这个项目的初衷是用来学习 eBPF/XDP,我在这方面的经验比较少,写的过程中和 eBPF Verifier 搏斗地很痛苦,一些报错也没有理解透彻而是想办法绕过了。如果文中有说得不准确的地方欢迎大家指正。

为了方便有兴趣的读者测试程序,我这边的开发环境是:Debian 5.10.158-2 + rustc 1.66.0 + llvm 15

0x01 What is eBPF/XDP

eBPF 是一项起源于 Linux 内核的革命性技术,可以在内核中运行一个沙盒程序而无需更改内核源代码或加载内核模块。

XDP(eXpress Data Path) 是基于 eBPF 技术的一种网络数据包处理框架,能够在比较早的阶段进行数据包处理,可以达到比较好的性能。

0x02 技术原理

在 XDP 程序中可以对流量包进行解析,忽略(XDP_PASS)不感兴趣的数据包,并根据需要对数据包进行修改(XDP_PASS)、丢弃(XDP_DROP)等操作。

首先处理以太网头和IP头,对数据包按照 ethhdr + iphdr 结构进行解析,通过 iphdr.protocol 字段可以判断传输层协议类型,我们这里只对 UDP 包感兴趣。

1
2
3
4
5
6
7
8
9
    let eth = ptr_at::<ethhdr>(&ctx, 0).ok_or(xdp_action::XDP_PASS)?;
    if unsafe { u16::from_be((*eth).h_proto) } != ETH_P_IP {
        return Ok(xdp_action::XDP_PASS);
    }
    let ip = ptr_at::<iphdr>(&ctx, ETH_HDR_LEN).ok_or(xdp_action::XDP_PASS)?;
    if unsafe { (*ip).protocol } != IPPROTO_UDP {
        return Ok(xdp_action::XDP_PASS);
    }
    trace!(&ctx, "received a UDP packet");

继续对数据包按照 udphdr 进行解析,只留下 udphdr.source 字段等于 53 的数据包,即我们想要处理的 DNS 响应包。

1
2
3
4
5
6
7
    let udp = ptr_at_mut::<udphdr>(&ctx, ETH_HDR_LEN + IP_HDR_LEN).ok_or(xdp_action::XDP_PASS)?;
    unsafe { (*udp).check = 0 };
    let destination_port = unsafe { u16::from_be((*udp).dest) };
    let src_port = unsafe { u16::from_be((*udp).source) };
    if src_port != 53 {
        return Ok(xdp_action::XDP_PASS);
    }

这里开始进行 DNS 响应包的解析工作,DNS 响应包有一个 12 字节的头,解析后可以得到后续 Questions 、Answers 等结构的的长度

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
#[repr(C)]
#[derive(Copy, Clone)]
pub struct dnshdr {
    pub trans_id: u16,
    pub flags: u16,
    pub qcount: [u8;2],
    pub acount: [u8;2],
    // authority_rrs
    pub nscount: [u8;2],
    // additional_rrs
    pub arcount: [u8;2],
}

DNS 包头后面是 Questions 部分,包含 Question 的个数由 dnshdr.qcount 指定, 相应的 Questions 部分后面是 dnshdr.acount 个 Answer 结构。

Question 结构包含 3 个字段:name, type, class, 其中 type 和 class 各占 2 字节,name 的长度非固定,需要动态解析,这也是在 eBPF 中比较难写的部分。

name 即主机名,在数据包中会进行编码,比如 www.bing.com 会被编码成 \x03www\x04bing\x03\com\x00 , 即把主机名按照域名级数拆分成多个标签(label),然后按照 [标签长度(单字节)]+[标签] 为一组的形式进行编码,最后以 \x00 结尾。

在 eBPF 中的写循环语句 Verifier 很容易报错,比如不能用变量作为循环次数,实际使用中使用合适的常量作为最大循环次数,再在循环内做检查按需 break 是比较容易通过检查的写法。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
    // 0..5: support a.b.c.d
    for i in 0..5 {
        // 读取单字节内容长度
        let num = unsafe { *(ptr_at::<u8>(&ctx, j)?)  };
        j += 1; // 数据指针加一
	// 内容长度等于 0 意味着读完
        if num == 0 {
            break;
        }
        // 数据指针跳过这段内容
        j += num as usize;
    }

Answer 的结构包含6个字段:name, type, class, ttl, data_length, data, 其中 type 字段表示回答的类型,常见有 A, CNAME 等,一个 DNS 响应包中可能会包含多个 Answer 结构,我们这里做的是找到第一个 A 类型 Answer,并把结构中 data(此时是IP地址)替换成加速IP。

我们依据第一个 Question 中的 name 来判断请求的域名是否为 Github 相关域名,这里就需要有一个域名和 IP 的映射关系,项目里面使用的数据来源于 ineo6/hosts 项目,该项目提供了加速国内 Github 访问的 hosts 文件。

eBPF 程序支持 maps 数据结构来在用户态和内核态之间传递数据,我们在用户态程序中读取最新的 Github hosts 文件并解析存储到 maps 里面供 eBPF 程序取用,这里为了方便,key 为 [u8;256] 存储编码后的主机名 ,value 为 [u8;4] 存储 IP地址 (用主机名的 hash 值来做 key感觉好一点) 。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
// eBPF maps
#[map(name = "GITHUB_HOSTS")]
static mut GITHUB_HOSTS: HashMap<[u8;256], [u8;4]> =
    HashMap::<[u8;256], [u8;4]>::with_max_entries(256, 0);

// 查询
let ip = match unsafe { GITHUB_HOSTS.get(&query) } {
    Some(backends) => {
        info!(&ctx, "found github hosts");
        backends
    }
    None => {
        info!(&ctx, "not github hosts");
        return Ok(xdp_action::XDP_PASS);
    }
};

最后,写入修改后的 ip 地址,done!

这么写实在是丑陋,我尝试用 core::ptr::copy_nonoverlapping 但总是过不了 Verifier,只好先这样。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
    let ip0 =  ptr_at_mut::<u8>(&ctx, (j as usize +0) + DNS_HDR_LEN_ALL).ok_or(xdp_action::XDP_PASS)?;
    let ip1 =  ptr_at_mut::<u8>(&ctx, (j as usize +1) + DNS_HDR_LEN_ALL).ok_or(xdp_action::XDP_PASS)?;
    let ip2 =  ptr_at_mut::<u8>(&ctx, (j as usize +2) + DNS_HDR_LEN_ALL).ok_or(xdp_action::XDP_PASS)?;
    let ip3 =  ptr_at_mut::<u8>(&ctx, (j as usize +3) + DNS_HDR_LEN_ALL).ok_or(xdp_action::XDP_PASS)?;
    info!(&ctx, "old ip: {}.{}.{}.{}", unsafe{*ip0}, unsafe{*ip1}, unsafe{*ip2}, unsafe{*ip3});
    unsafe { *ip0 = ip[0] }
    unsafe { *ip1 = ip[1] }
    unsafe { *ip2 = ip[2] }
    unsafe { *ip3 = ip[3] }
    info!(&ctx, "new ip: {}.{}.{}.{}", unsafe{*ip0}, unsafe{*ip1}, unsafe{*ip2}, unsafe{*ip3});
    return Ok(xdp_action::XDP_PASS);

还有,由于修改了数据包,checksum 发生变化,这里的做法是把 udphdr.check 字段置为 0 了,省得去重新计算 checksum 了。

1
unsafe { (*udp).check = 0 };

最后的运行效果如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
$ RUST_LOG=debug cargo xtask run
add github hosts: github.io: 185.199.108.153
add github hosts: github.io: 185.199.108.153
add github hosts: github.com: 140.82.113.4
add github hosts: api.github.com: 140.82.114.5
add github hosts: raw.githubusercontent.com: 185.199.108.133
[...]
[2023-01-05T10:33:44Z INFO  github_hosts] Waiting for Ctrl-C...
[2023-01-05T10:33:58Z INFO  github_hosts] received a DNS packet
[2023-01-05T10:33:58Z INFO  github_hosts] found github hosts
[2023-01-05T10:33:58Z INFO  github_hosts] old ip: 185.199.111.133
[2023-01-05T10:33:58Z INFO  github_hosts] new ip: 185.199.108.133

0x03 参考链接