偷天换日2.0:篡改光猫的IPv6地址自动分配

1. 背景

这是一篇神奇的文章。在这篇文章里,你将会看到:

  • 如何将一台双口linux设备变成一台交换机
  • 如何修改ICMPv6数据包中的DNS信息并发送IPv6数据包
  • 如何修改IPv6数据包中的来源IP地址并发送以太网帧

下图是笔者的网络架构(部分)。PC自动从光猫处获取IPv6地址,并将其网关/DNS指向光猫:

笔者家庭网络架构

但光猫的DNS存在DNS污染、无法自定义解析规则等问题,且安卓设备无法手动设置IPv6的DNS,所以笔者在之前的文章[1][2]里直接禁用了光猫的IPv6地址自动分配,家庭局域网内基本只使用IPv4。

但禁用IPv6始终是个缺憾。有没有可能实现,在保留光猫IPv6地址自动分配的基础上,实现自定义DNS呢?这是本文想要讨论的问题。

预期网络架构

2. 思路

基于上面的动图回顾一下IPv6 SLAAC地址自动分配的原理:

  • PC1向局域网广播ICMPv6 Router Solicitation(RS)数据包
  • 路由器收到RS包后广播ICMPv6 Router Advertisement (RA)数据包
  • PC1收到RA数据包后得知IPv6网段前缀,并生成自己的IPv6地址

具体到笔者的环境,光猫发出的RA包里还携带了附加的DNS服务器地址信息:

那么实现自定义DNS的思路就比较明确了:

  • PC正常向局域网广播RS数据包
  • 增加一台软交换机,让PC无法直接收到光猫广播的RA数据包
  • 软交换机收到光猫广播的RA数据包后,修改其中的DNS信息,并重新进行广播
  • PC收到修改后的RA数据包,生成自己的IPv6地址

改造思路

3. 设置软交换机

笔者使用广受好评的友善NanoPi R2S作为这里的软交换机,系统则使用的是友善Ubuntu Core 20.04改版。

友善R2S

将光猫和PC分别连接至R2S的两个网口后,在Ubuntu上配置一个连接两个网口的网桥,即可让R2S化身一台软交换机(详细说明见Ubuntu社区文档:NetworkConnectionBridge - Community Help Wiki):

1
2
sudo apt-get install bridge-utils
sudo vim /etc/network/interfaces.d/br0
1
2
3
4
5
6
7
8
9
10
11
auto br0
iface br0 inet static
address 192.168.1.5
netmask 255.255.255.0
gateway 192.168.1.1
# dns-nameservers 192.168.1.1
bridge_ports eth0 eth1
bridge_stp off
bridge_fd 0
bridge_maxwait 0
iface br0 inet6 auto
1
sudo /etc/init.d/networking restart

配置好后可通过brctl命令可以看到效果:

同时,我们还可以通过ebtables禁止来自光猫的RA包广播到网桥上的其它设备:

1
sudo ebtables -A FORWARD -p IPv6 -s {光猫MAC地址} --ip6-proto ipv6-icmp --ip6-icmp-type router-advertisement -j DROP

4. 修改ICMPv6数据包

接着我们需要在这台软交换机上对ICMPv6 RA数据包进行加工。整体逻辑并不复杂:

1
2
3
4
5
6
for {
监听ICMPv6数据包
过滤RA数据包
修改其中的DNS信息
广播修改后的RA数据包
}

Golang的官方包golang.org/x/net/icmp能很方便做到这一点:

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
var (
mcastIP = net.ParseIP("ff02::1") // 组播ip
)
func main() {
c, err := icmp.ListenPacket("ip6:ipv6-icmp", "::") // 监听ICMPv6数据包
...
for {
// 监听ICMPv6数据包
rb := make([]byte, 1500)
n, peer, err := c.ReadFrom(rb)
...
var zone string
if parts := strings.SplitN(peer.String(), "%", 2); len(parts) == 2 {
zone = parts[1]
}
...
// 过滤RA数据包
const ProtocolIPv6ICMP = 58
rm, err := icmp.ParseMessage(ProtocolIPv6ICMP, rb[:n])
...
if rm.Type != ipv6.ICMPTypeRouterAdvertisement {
continue
}
body, err = rm.Body.Marshal(int(ipv6.ICMPTypeRouterAdvertisement))
...
// 修改其中的DNS信息
err = replaceDNS(body)
...
rm.Body = &icmp.RawBody{Data: body}

// 广播修改后的RA数据包
wb, err := rm.Marshal(nil)
...
_, err = c.WriteTo(wb, &net.IPAddr{IP: mcastIP, Zone: zone})
...
}
}

问题的关键在于如何修改RA数据包中的DNS。虽然通过分析Wireshark抓包,我们可以获得DNS在RA数据包中的具体位置(数组下标),但这种依赖Magic Number的代码显然缺少通用性。

维基百科[4]和RFC6106[5]介绍了RA数据包、DNS Option的具体格式:

于是我们知道,RA数据包的16字节后是Options区域(icmp库已去除了前4个字节)、Option的前两个字节分别是类型和Option长度(以8字节为单位)。所以,我们可以通过下列代码查找并替换RA包中的DNS地址:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
var (
routerIP = net.ParseIP("fe80::1") // 光猫ip
dnsIP = net.ParseIP("fe80::xx") // 自定义dns服务器ip
)
func replaceDNS(body []byte) error {
for idx := 12; idx < len(body)-1; idx += int(body[idx+1]) * 8 { // 遍历所有option
const optTypeDNS = 25
if optType := body[idx]; optType == optTypeDNS { // 定位dns option
ipL, ipR := idx+8, idx+8+16
if oldDNS := body[ipL:ipR]; !reflect.DeepEqual(oldDNS, []byte(routerIP)) { // 防止递归替换
return fmt.Errorf("dns in RA (%s) not equal %s", net.IP(oldDNS), routerIP)
}
_ = append(body[ipL:ipL], dnsIP...) // 原地替换dns ip
return nil
}
}
return fmt.Errorf("dns option not found")
}

5. 修改IPv6数据包

Wireshark抓包的结果显示PC确实收到了修改后的RA数据包,但PC却并没有像预想的那样正常获取到IPv6地址。

由于PC收到的是软交换机重新广播的RA包,所以其IPv6数据包首部的来源IP地址变成了软交换机的IPv6地址(fe80::1c:xx),而不是原始RA包中的光猫IPv6地址(fe80::1)。此时笔者有了一个大胆的猜想,是不是这个来源IP地址的问题呢?如果是的话,我们只要能再修改这个值,就能让PC正常获取到IPv6地址了。

要修改这个地址,我们需要做两件事情:

  1. 重新计算ICMPv6包的校验和。维基[4]上介绍校验和需要构造包含来源/目标IP地址的pseudo-header,还好上文提到的icmp库已经封装了这个功能:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    var (
    routerIP = net.ParseIP("fe80::1") // 光猫ip
    mcastIP = net.ParseIP("ff02::1") // 组播ip
    )
    func main() {
    ...
    for {
    ...
    // 修改其中的DNS信息
    err = replaceDNS(body)
    ...
    rm.Body = &icmp.RawBody{Data: body}

    // 自定义pseudo-header,重计算校验和
    head := icmp.IPv6PseudoHeader(routerIP, mcastIP)
    wb, err := rm.Marshal(head)
    if err != nil {
    ...
    }
    // 广播修改后的RA数据包
    err = sendMsg(zone, wb)
    ...
    }
    }
  2. 避免系统自动设置来源IP地址,比如直接发送构造好的以太网数据帧。经过一番搜寻,笔者发现stackoverflow上[7]已经有人提供了类似的demo,笔者只需要参考IPv6数据包的格式[8]组装IPv6数据即可:

    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
    func sendMsg(zone string, icmpBody []byte) error {
    // 指定来源mac地址&目标mac地址
    iface, err := net.InterfaceByName(zone)
    if err != nil {
    return fmt.Errorf("get link by name failed: %w", err)
    }
    srcMac := iface.HardwareAddr
    dstMac := []byte{0x33, 0x33, 0x00, 0x00, 0x00, 0x01} // IPv6mcast_01
    // 组装以太网帧头部
    data := []byte{
    dstMac[0], dstMac[1], dstMac[2], dstMac[3], dstMac[4], dstMac[5],
    srcMac[0], srcMac[1], srcMac[2], srcMac[3], srcMac[4], srcMac[5],
    0x86, 0xdd, // Type: IPv6
    }
    // 组装IPv6头部
    length := make([]byte, 2)
    binary.BigEndian.PutUint16(length, uint16(len(icmpBody)))
    data = append(data, []byte{
    0x60, 0x00, 0x00, 0x00, // ipv6.version
    length[0], length[1], // Payload Length
    0x3a, // Next Header: ICMPv6 (58)
    0xff, // Hop Limit: 255
    }...)
    data = append(data, routerIP...) // 来源ip地址
    data = append(data, mcastIP...) // 目标ip地址
    // 组装ICMPv6数据
    data = append(data, icmpBody...)

    // 发送以太网帧
    fd, err := syscall.Socket(syscall.AF_PACKET, syscall.SOCK_RAW, int(htons(syscall.ETH_P_ALL)))
    if err != nil {
    return fmt.Errorf("call socket failed: %w", err)
    }
    addr := syscall.SockaddrLinklayer{
    Ifindex: iface.Index,
    Halen: 6, // Ethernet address length is 6 bytes
    Addr: [8]byte{dstMac[0], dstMac[1], dstMac[2], dstMac[3], dstMac[4], dstMac[5]},
    }
    err = syscall.Sendto(fd, data, 0, &addr)
    if err != nil {
    return fmt.Errorf("call sendto failed: %w", err)
    }
    return nil
    }

6. 效果

最后的效果证明笔者的猜想没错。在手动将IPv6数据包的来源IP地址设置为fe80::1后,PC正常获取到了IPv6地址,并设置了自定义DNS。唯一美中不足的是,PC的首选DNS地址地址还是光猫。不过我们还可以在软交换机上打个补丁,禁止向光猫发送DNS请求:

1
sudo ebtables -A FORWARD -p IPv6 -d {光猫MAC地址} --ip6-protocol udp --ip6-destination-port 53 -j DROP

这样,PC就只能使用备用DNS,也就是我们自定义的DNS了。

尽管最终效果并不完美,这篇文章的解决方案也并不实用(软路由+桥接才是终极解决方案),但相比于结果,笔者更享受的是发现问题、解决问题的过程。能写出这篇文章真是太棒了!

引用


偷天换日2.0:篡改光猫的IPv6地址自动分配
https://www.yooo.ltd/2023/02/17/modify-slaac/
作者
OrangeWolf
发布于
2023年2月17日
许可协议