GripDev

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 请求,所以我做了一些调查。

现在我感到困惑。

什么!这些相互矛盾!

阅读更多 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 地址编码到 IPv6 地址中的方法。

ipv4 mapped address image

使用时,您会获得一个 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.