偷天换日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改版。
将光猫和PC分别连接至R2S的两个网口后,在Ubuntu上配置一个连接两个网口的网桥,即可让R2S化身一台软交换机(详细说明见Ubuntu社区文档:NetworkConnectionBridge - Community Help Wiki):
1 |
|
1 |
|
1 |
|
配置好后可通过brctl
命令可以看到效果:
同时,我们还可以通过ebtables
禁止来自光猫的RA包广播到网桥上的其它设备:
1 |
|
4. 修改ICMPv6数据包
接着我们需要在这台软交换机上对ICMPv6 RA数据包进行加工。整体逻辑并不复杂:
1 |
|
Golang的官方包golang.org/x/net/icmp能很方便做到这一点:
1 |
|
问题的关键在于如何修改RA数据包中的DNS。虽然通过分析Wireshark抓包,我们可以获得DNS在RA数据包中的具体位置(数组下标),但这种依赖Magic Number的代码显然缺少通用性。
维基百科[4]和RFC6106[5]介绍了RA数据包、DNS Option的具体格式:
于是我们知道,RA数据包的16字节后是Options区域(icmp
库已去除了前4个字节)、Option的前两个字节分别是类型和Option长度(以8字节为单位)。所以,我们可以通过下列代码查找并替换RA包中的DNS地址:
1 |
|
5. 修改IPv6数据包
Wireshark抓包的结果显示PC确实收到了修改后的RA数据包,但PC却并没有像预想的那样正常获取到IPv6地址。
由于PC收到的是软交换机重新广播的RA包,所以其IPv6数据包首部的来源IP地址变成了软交换机的IPv6地址(fe80::1c:xx
),而不是原始RA包中的光猫IPv6地址(fe80::1
)。此时笔者有了一个大胆的猜想,是不是这个来源IP地址的问题呢?如果是的话,我们只要能再修改这个值,就能让PC正常获取到IPv6地址了。
要修改这个地址,我们需要做两件事情:
重新计算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
24var (
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)
...
}
}避免系统自动设置来源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
44func 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 |
|
这样,PC就只能使用备用DNS,也就是我们自定义的DNS了。
尽管最终效果并不完美,这篇文章的解决方案也并不实用(软路由+桥接才是终极解决方案),但相比于结果,笔者更享受的是发现问题、解决问题的过程。能写出这篇文章真是太棒了!