eBPF 谜题:何时 IPv4 不是 IPv4?当它伪装成 IPv6 时
eBPF 谜题:何时 IPv4 不是 IPv4?当它伪装成 IPv6 时!
2025 年 5 月 6 日
这个冒险始于一个简单的 eBPF 程序,它透明地重定向单个程序(或 Docker 容器)在 53 端口上的 DNS 请求。
为此,我在一个 cgroup
上使用了 BPF_CGROUP_INET4_CONNECT
。这让我可以在 cgroup
内部发生 syscall.connect
时检查和重定向流量。这是一个简化版本 👇
int handle_connect_redirect(struct bpf_sock_addr *ctx, __be32 original_ip,
bool is_connect4, struct redirect_result *result) {
__be32 new_ip = original_ip;
__be16 new_port = ctx->user_port;
if (ctx->user_port == bpf_htons(53)) {
new_ip = const_mitm_proxy_address; // Our MITM DNS server we're using for intercept
new_port = bpf_htons(const_dns_proxy_port);
}
result->is_redirected = did_redirect;
result->ip = new_ip;
result->port = new_port;
return 1;
}
SEC("cgroup/connect4")
int connect4(struct bpf_sock_addr *ctx) {
struct redirect_result r = {
.ip = ctx->user_ip4,
.port = ctx->user_port,
.is_redirected = false,
};
handle_connect_redirect(ctx, ctx->user_ip4, true, &r);
if (r.is_redirected) {
// If we redirected the request then we need to update the socket
// destination to the new IP and port
ctx->user_ip4 = r.ip;
ctx->user_port = r.port;
}
return 1;
}
运行该程序的机器不支持 IPv6,所以我认为我已经覆盖了所有情况。
然后,我使用了一个 BPF_PROG_TYPE_CGROUP_SKB
eBPF 程序,以确保无法通过直接 IP 调用来规避重定向。大致如下 👇
SEC("cgroup_skb/egress")
int cgroup_skb_egress(struct __sk_buff *skb) {
// Block IPv6 traffic. Currently not supported.
if (skb->family == AF_INET6) {
struct event info = {
...
.eventType = PACKET_IPV6_TYPE,
};
bpf_ringbuf_output(&events, &info, sizeof(info), 0);
return EGRESS_DENY_PACKET;
}
// .... then we'd check if outbound ip was allowed and deny/allow ...
注意:我在这里使用
bpf_ringbuf_output
来跟踪 eBPF 程序中的事件,并从用户空间记录它们。这对于追踪这个错误非常宝贵,没有它们,很难推断程序 eBPF 部分内部发生了什么。
一切进展顺利,直到用户尝试 dotnet
CLI。
当他们运行 dotnet add package x
时,它会无限期地挂起,并通过 ringbuf_output
产生大量的 PACKET_IPV6_TYPE
阻塞消息。
哦,dotnet 正在使用 IPv6
显而易见的结论是,机器正在以某种方式发出 IPv6 请求,所以我做了一些调查。
- ❓ 我的
connect4
eBPF 程序没有被命中,我添加了类似的bpf_ringbuf_output
事件,以便我可以流式传输到用户空间的日志。 - ❌ 连接
wireshark
后,我确认dotnet
没有从盒子发出 IPv6 调用,并且该盒子无法向互联网发出 IPv6 请求。
现在我感到困惑。
- 网络流量显示正在发出 IPv4 调用。
egress
eBPF 程序显示 IPv6。connect4
eBPF 程序没有触发,表明没有进行 IPv4connect
调用。
什么!这些相互矛盾!
阅读更多 Kernel 和 dotnet
源代码
此时,我知道一定有一些我误解的地方。
我花了很多时间深入研究 Kernel、eBPF 和 dotnet
,看看是否能找到任何使 dotnet cli
特殊的东西,因为似乎没有其他工具受到影响。
我开始更改程序以获取更多信息。我添加了一个 connect6
eBPF 程序,并 hook 了它,以便在 IPv6 上进行 syscall.connect
时会命中它。希望它能确认 dotnet
确实以某种方式使用了 IPv6?!
SEC("cgroup/connect6")
int connect6(struct bpf_sock_addr *ctx) {
struct event info = {
.eventType = IPV4_VIA_IPV6_REDIRECT_TYPE,
};
bpf_ringbuf_output(&events, &info, sizeof(info), 0);
return 1;
}
使用上面的 hook 运行重现步骤 dotnet add package x
后,我立即收到 connect6
被命中的消息,即使 wireshark
显示 IPv4 流量正在离开 VM。
此时,我能想到的唯一结论是 Kernel 看到的是 IPv6,但流量实际上是 IPv4。
这触发了我对双栈网络的回忆。在 dotnet
仓库中挖掘后,我发现了这个:
自 .NET 5 以来,我们在
SocketsHttpHandler
中使用DualMode sockets
。这允许我们处理来自 IPv6 socket 的 IPv4 流量,并且被 RFC 1933 认为是一种有利的做法。
该功能有一个 kill switch!我试了一下,禁用 DualMode sockets
后,一切都按预期工作,connect4
被命中,egress
没有将该请求视为 IPv6。🚀
问题是如何?这个功能在底层做了什么。
IPv4-Compatible IPv6 Address
或 "IPv4 假装是 IPv6 一小会儿"
dotnet
DualMode
socket 文档中的这一行 👇 感觉像是关键 🗝
这允许我们处理来自 IPv6 socket 的 IPv4 流量
但是如何!深入挖掘源代码和 Kernel 后,我发现:
IPv4-mapped IPv6 addresses
这是一种将 IPv4 地址编码到 IPv6 地址中的方法。
使用时,您会获得一个 IPv6 地址,其中最后 32 位实际上是一个 IPv4 地址。
我更新了我的 connect6
eBPF,将 IPv6 地址输出到我的 bpf_ringbuf_output
事件,以便我可以查看它。
结果它是一个 IPv4 mapped address
🤯🎉
当使用 DualMode sockets
时,dotnet
会请求一个 IPv6 Socket,即使对于非 IPv6 请求,并将 user_ip6
地址字段设置为一个 IPv4-Mapped 地址。
IPv4 mapped address 是什么样子的? 它们看起来像
::ffff:1.1.1.1
,在 IPv6 地址的末尾编码 IPv4 地址。
我以为我一定搞错了,难道你不能只是将一个 IPv4 地址砸入 IPv6 字段,然后奇迹就发生了吗?!不,我没有搞错,这就是发生的事情。 Linux 支持这一点,并将继续将该请求作为 IPv4 路由。
我的 wireshark
跟踪没有看到 IPv6 流量,因为 Kernel 在发出网络调用时将其转换回 IPv4,因此这种中间状态仅对 eBPF
程序/Kernel 可见。
修复 eBPF 以处理 IPv4-mapped IPv6 addresses
为了使我最初的 syscall.connect
拦截工作,我现在必须 hook IPv4 和 IPv6 版本。 在这种情况下,我更新了之前的 connect6
,以从 IPv6 地址解析出 IPv4 地址。
SEC("cgroup/connect6")
int connect6(struct bpf_sock_addr *ctx) {
// Check if we have an IPv4-mapped IPv6 address (::ffff:x.x.x.x)
// The first 10 bytes should be zeros, followed by 2 bytes of 0xffff
// user_ip6[0] and user_ip6[1] should be 0 (first 64 bits)
// user_ip6[2] should be 0x0000ffff (next 32 bits with pattern 0000...1111)
if (ctx->user_ip6[0] != 0 || ctx->user_ip6[1] != 0 ||
ctx->user_ip6[2] != bpf_htonl(0x0000ffff)) {
return 1;
}
// See: https://en.m.wikipedia.org/wiki/IPv6#IPv4-mapped_IPv6_addresses As
// bpf_sock_addr stores `user_ip6` as IPv6 is 4x32 bits to get the IPv4
// address we ignore the first 96 bits and take the last 32 bits which is the
// __u32 at index 3 of the user_ip6 array
__be32 ipv4_address = ctx->user_ip6[3];
struct event info = {
.ip = bpf_ntohl(ipv4_address),
.eventType = IPV4_VIA_IPV6_REDIRECT_TYPE,
};
bpf_ringbuf_output(&events, &info, sizeof(info), 0);
struct redirect_result r = {
.ip = ipv4_address,
.port = ctx->user_port,
.is_redirected = false,
};
handle_connect_redirect(ctx, ipv4_address, false, &r);
if (r.is_redirected) {
// If we redirected the request then we need to update the socket
// destination to the new IP and port
ctx->user_ip6[3] = r.ip;
ctx->user_port = r.port;
}
return 1;
}
它提取出原始 IPv4 地址,然后调用现有的 handle_connect_redirect
方法。完成了吗?不。
这还不够,因为我的 egress
eBPF 程序仍然会将这些请求阻止为 IPv6。
我将其追踪到 egress
程序中的这个检查:
if (skb->family == AF_INET6) {
// end up here
return 0
}
映射的 IPv4 socket 将其 family 标识为 IPv6,我该如何解决这个问题? 我想阻止 IPv6,但不阻止通过 IPv6 sockets 映射的 IPv4 🤔
经过大量的尝试,我找到了这种方法:
if (skb->protocol == bpf_htons(ETH_P_IPV6)) {
// IPv6 hits this but IPv4 Mapped doesn't
return 0
}
通过查看 skb
上的协议,我能够区分 IPv6 和映射到 IPv6 上的 IPv4(待办事项:需要对最后一点进行更多测试)。
就是这样
何时 IPv4 不是 IPv4?
当它使用 IPv4-Compatible IPv6 Address
通过 IPv6 socket 发送 IPv4 时 🤯
ps. 我有阅读障碍,如果你发现错误,请告诉我,我有时会错过它们。欢迎提出拉取请求/问题 🔧 © 2025 Lawrence Gripper.