下一站 - Ihcblog!

远方的风景与脚下的路 | 子站点:ihc.im

0%

eBPF with Rust 的一些有趣尝试

最近做了两个有关 eBPF 的尝试,感觉还挺有意思,分享一下。

What is eBPF?

eBPF 是对 BPF 的扩展,它的使用一套自定义的类似 RISC-V 的指令集,执行时它工作在内核虚拟机中。加载 eBPF 到内核后,会先经过内核的验证,通过后则可以基于 jit 高效执行。

过去 BPF 常用于 perf 和 trace;现在使用 eBPF 我们还可以做一些自定义的业务逻辑。比起将逻辑插入为用户态的 Proxy,在内核态做这件事一来可以避免内核和用户态上下文切换;二来可以尽早接管数据包,可能可以避免协议栈开销。

利用 eBPF 过滤 DNS 污染

https://github.com/ihciah/clean-dns-bpf

国庆假期无聊,看到一个推文,觉得挺有意思,居然手写字节码搞出来了 DNS 污染过滤。但明显我不是手艺人,我想既高度可读,又把功能给做出来。

GFW 污染 DNS 的方式为抢答,我们只需要判断并丢弃投毒响应即可获得正确的解析结果。要实现判断这种自定义的逻辑,一个办法是做一个 proxy。这件事无数人做过了,我就不折腾了。另一个办法是把代码搞进内核。

把代码搞进内核并不容易。要么改代码自己编译内核,要么搞内核模块,要么 BPF/eBPF。前面两个用起来太难受,可能一不小心就崩了,并且我也没有精力跟进 linux 的内核版本更新;那么唯一看起来好用的就是 eBPF 了:虽然有一些限制,但正因为如此,它通用、可移植性好,且在内核虚拟机里面跑不会搞崩内核。

由于不会写 C,尝试了一下 Rust + RedBPF 看起来可行。在 XDP 上做手脚应该可以做到理论上最好的性能,因为这是能摸到的最早的地方了。并且对于一部分网卡,甚至可以卸载到硬件执行。

然后掏出 Wireshark 抓一下,试着用 8.8.8.8 在有墙情况下查询了几个域名。

以 twitter.com 为例,当向 8.8.8.8 请求 twitter.com 的 A 记录时,正常的响应会返回 2 条结果(1Q2A);而 GFW 只会返回 1 条,但是使用了 2 次抢答。2 次抢答包其中一个 IP Identification = 0x0000,另一个 IP Flags = 0x40(Don’t fragment);而正常的响应 IPID 不会是 0 并且 IP Flags = 0。

我们只要 Drop 掉符合对应特征的包即可。这时我们可以验证,twitter.com 可以正确解析(fb 等非 google 服务也正常)。

但对于 google.com,这种办法并没有预期的表现。正常的响应 DNS Flags = 0x8180,而抢答包出现了 0x8590(额外标记 Authoritative 和 Answer Authenticated),0x85a0(额外标记 Authoritative 和 Non-authenticated data: Acceptable)和 0x8580(额外标记 Authoritative) 三种;并且,正常的响应 Answer 中使用 c00c(0b11 + offset) 来复用 Query 中的 Name,抢答响应则重复又写了一遍。

为了避免误杀,我们可以先放行多个 Answer 的包(因为观测到抢答包里只有单个 Answer)。之后如果标记了 Authoritative,但是 Authority RRs = 0(不确定这个字段我是不是理解对了),则 Drop。c00c 这个特征也可以作为判断依据,但是要做较多解析和计算,暂时不使用。

这些过滤做完就可以正常拿到 google.com 的 A 记录啦~

这时我们可以验证,google 系的域名也可以正确解析。

更易用

我们东西可以 work 了,但还不够好用:用户需要以 daemon 形式跑我的可执行文件,由我的可执行文件来管理 eBPF 的注入。

但我们做的事情只有注入 eBPF 逻辑呀!哪有运行 daemon 的必要呢。查了一圈发现可以使用 ip 命令直接加载 eBPF 到网卡,但是我编译出来的 elf 怎么都挂不上去。查了一圈,在一个小角落发现了同病相怜的人,要 rename 主 section 为 prog,并移除一些 section。顺便再干掉一些多余的 debug 信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
llvm-objcopy \
--remove-section .debug_loc \
--remove-section .debug_info \
--remove-section .debug_ranges \
--remove-section .BTF.ext \
--remove-section .eh_frame \
--remove-section .debug_line \
--remove-section .debug_pubnames \
--remove-section .debug_pubtypes \
--remove-section .debug_abbrev \
--remove-section .debug_str \
--remove-section .text \
--remove-section .BTF \
--remove-section .symtab \
--remove-section .rel.BTF \
--rename-section xdp/clean_dns=prog \
./clean-dns.elf

之后就可以通过 ip link set dev eth0 xdp obj ./clean-dns.elf 来加载了。

项目发布后,issue 里很多人说加载不上去,我也很疑惑,但无法复现。后来有好心人 PR 说,xdpdrv模式并非每类网卡都可用,然后通过手动指定 xdpgeneric 模式,就可以了。

利用 eBPF 做数据转发

https://github.com/ihciah/socks5-forwarder

之前有一个个人需求,我要让一个不支持 socks5 代理的客户端通过 socks5 代理连接固定的远端服务器。

只是实现需求的话还是很 easy 的:直接基于 Tokio 转呗,还有现成的 socks5 组件可以用。之后花了半个小时把代码搞了出来(在这),并平稳运行。

虽然这东西根本不是性能瓶颈,但是我看 Proxy 就是不爽,总觉得这东西在浪费算力。我们的目标是辅助握手,那么能不能在辅助握手完把拷贝的活丢给 kernel 干呢?拷贝来拷贝去,还搭上切换开销,实在没必要。Proxy 方案搞完 3 个月后,我开始尝试新的方案了。

Linux 提供了 splice,可以零拷贝地将数据在 fd 和一个 Pipe 之间丢来丢去。在一个类似功能的项目里我也找到了利用 splice + Pipe 做零拷贝的实现

eBPF 这么神通广大,这点事按理说也能干,甚至还能干的更好(比如我要对后续的数据做简单加密,这事走 splice 就干不来)。

我想让内核能够识别出某个 socket 的数据,并将其直接重定向到另一个 socket。本例中通过用户态代码操作 sockmap 和 hashmap,这两个 map 为用户代码和 bpf 代码共享的。

这样在我们需要将某个 socket 劫持掉并直接转发时,我们只需要将 ip:port 以及转发的目的 socket 通过这两个 map 告诉内核部分代码。这样在 bpf 处理数据时,就可以判别连接并直接转发。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
#[stream_verdict]
fn verdict(skb: SkBuff) -> SkAction {
let (ip, port, lip, lport) = unsafe {
let remote_ip_addr = (skb.skb as usize + offset_of!(__sk_buff, remote_ip4)) as *const u32;
let remote_port_addr = (skb.skb as usize + offset_of!(__sk_buff, remote_port)) as *const u32;
let local_ip_addr = (skb.skb as usize + offset_of!(__sk_buff, local_ip4)) as *const u32;
let local_port_addr = (skb.skb as usize + offset_of!(__sk_buff, local_port)) as *const u32;
(ptr::read(remote_ip_addr), ptr::read(remote_port_addr), ptr::read(local_ip_addr), ptr::read(local_port_addr))
};


let key = IdxMapKey { addr: ip, port };
if let Some(idx) = unsafe {IDX_MAP.get(&key)} {
return match unsafe { SOCKMAP.redirect(skb.skb as *mut _, *idx) } {
Ok(_) => {
SkAction::Pass
},
Err(_) => {
SkAction::Drop
},
};
}
let key = IdxMapKey { addr: lip, port: lport };
if let Some(idx) = unsafe {IDX_MAP.get(&key)} {
return match unsafe { SOCKMAP.redirect(skb.skb as *mut _, *idx) } {
Ok(_) => {
SkAction::Pass
},
Err(_) => {
SkAction::Drop
},
};
}
SkAction::Pass
}

之后在用户态只需要操作 map 来控制 bpf:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
async fn bpf_relay<O, IR, IW, OR, OW>(
bpf: Arc<Mutex<O>>,
in_conn_info: ConnInfo<IR, IW>,
out_conn_info: ConnInfo<OR, OW>,
) -> anyhow::Result<()>
where
O: BPFOperator<K = IdxMapKey>,
IR: AsyncRead + Unpin,
IW: AsyncWrite + Unpin,
OR: AsyncRead + Unpin,
OW: AsyncWrite + Unpin,
{
// used for delete from idx_map and sockmap
let mut inbound_addr_opt = None;
let mut outbound_addr_opt = None;


// add socket and key to idx_map and sockmap for ipv4
// Note: Local port is stored in host byte order while remote port is in network byte order.
// https://github.com/torvalds/linux/blob/v5.10/include/uapi/linux/bpf.h#L4110
if let (V4(in_addr), V4(out_addr)) = (in_conn_info.addr, out_conn_info.addr) {
let inbound_addr = IdxMapKey {
addr: u32::to_be(u32::from(in_addr.ip().to_owned())),
port: u32::to_be(in_addr.port().into()),
};
let outbound_addr = IdxMapKey {
addr: u32::to_be(u32::from(out_addr.ip().to_owned())),
port: out_addr.port().into(),
};
inbound_addr_opt = Some(inbound_addr);
outbound_addr_opt = Some(outbound_addr);
let mut guard = bpf.lock().unwrap();
let _ = guard.add(out_conn_info.fd, inbound_addr);
let _ = guard.add(in_conn_info.fd, outbound_addr);
}


// block on copy data
// Note: Here we copy bidirectional manually, remove from map ASAP to
// avoid outbound port reuse and packet mis-redirected.
tracing::info!("Relay started");


let (mut ri, mut wi) = (in_conn_info.read_half, in_conn_info.write_half);
let (mut ro, mut wo) = (out_conn_info.read_half, out_conn_info.write_half);
let client_to_server = async {
let _ = tokio::io::copy(&mut ri, &mut wo).await;
tracing::info!("Relay inbound -> outbound finished");
let _ = wo.shutdown().await;
if let Some(addr) = inbound_addr_opt {
let _ = bpf.lock().unwrap().delete(addr);
}
};


let server_to_client = async {
let _ = tokio::io::copy(&mut ro, &mut wi).await;
tracing::info!("Relay outbound -> inbound finished");
let _ = wi.shutdown().await;
if let Some(addr) = outbound_addr_opt {
let _ = bpf.lock().unwrap().delete(addr);
}
};


tokio::join!(client_to_server, server_to_client);
tracing::info!("Relay finished");


Ok::<(), anyhow::Error>(())
}

pub(crate) trait BPFOperator {
type K;


fn add(&mut self, fd: RawFd, key: Self::K) -> Result<(), Error>;
fn delete(&mut self, key: Self::K) -> Result<(), Error>;
}


pub struct Shared<'a, K>
where
K: Clone,
{
sockmap: SockMap<'a>,
idx_map: HashMap<'a, K, u32>,


idx_slab: slab::Slab<()>,
}


impl<'a, K> Shared<'a, K>
where
K: Clone,
{
pub fn new(sockmap: SockMap<'a>, idx_map: HashMap<'a, K, u32>, capacity: usize) -> Self {
Self {
sockmap,
idx_map,
idx_slab: slab::Slab::with_capacity(capacity),
}
}
}


impl<'a, KS> BPFOperator for Shared<'a, KS>
where
KS: Clone,
{
type K = KS;


fn add(&mut self, fd: RawFd, key: Self::K) -> Result<(), Error> {
let idx = self.idx_slab.insert(()) as u32;
self.idx_map.set(key, idx)?;
self.sockmap.set(idx, fd)
}


fn delete(&mut self, key: Self::K) -> Result<(), Error> {
if let Some(idx) = self.idx_map.get(key.clone()) {
self.idx_slab.remove(idx as usize);
self.idx_map.delete(key);
self.sockmap.delete(idx)
} else {
Ok(())
}
}
}

不过这个东西兼容性上似乎有点问题,依赖于内核的 BTF 支持。在 Arch with 5.14 上编译后可以在编译机和 Debian with 5.10 上使用;但是无法在 Debian 5.4 上使用——推测是因为 BTF 支持缺失或 helper 函数签名变更导致。

在四层代理中这套可以玩,在七层代理中应该也可行。用户态代码处理 header,读出 body 长度之后通过 map 告诉 bpf,之后内核转发 body。这事其实 splice 也能做,可能走 eBPF 性能更好,因为 stream verdict 更靠前(我处理的是 IP 包)。

不过从 syscall 次数上讲,操作 map 也是 syscall,相比 splice 方案的 3 次 syscall(1 次创建 Pipe,2 次 splice)也没便宜很多。所以感觉这套东西只对长转发和大包转发有意义。

搞这东西的过程中最郁闷的一点是,local_portremote_port 的字节序居然还不一样,并且没文档,最后翻 kernel 代码查出来的,是在下栽了。

结语

本文简要介绍了我在 eBPF 上的两个简单实践。关于 Linux 内还有哪些可用的 BPF hook 点和类型,可以参考这篇文章

欢迎关注我的其它发布渠道