Skip to main content

8.8 Multicast Listener Discovery (MLD)

In the previous section, we navigated the "maze" of packet delivery, ultimately routing packets to the correct Socket. That process resembled a series of security checks: first verifying the destination, then checking membership, and finally confirming the exact sender.

But you might wonder: how does a router know who the "members" are?

If we want to receive packets for ff02::1, we can't exactly run up to the router and shout about it. We need a protocol for hosts and routers to sit down and discuss "who wants to listen to whose broadcasts."

That's exactly what MLD (Multicast Listener Discovery) does. It's the IPv6 equivalent of IGMP, but with some key differences—and those differences are exactly what trip us up during debugging.

A Dialogue Between Two Worlds

The MLD protocol is essentially an asymmetric dialogue:

  • Routers: Constantly asking, "Is anyone here interested in ff05::1?"
  • Hosts: Raising their hands when they hear it, "I am!"

But that's just the surface. In practice, this mechanism is stuffed into the ICMPv6 pocket in IPv6. You might ask: why bundle it in? The answer is "a unified front." In the IPv4 era, IGMP was an independent protocol with its own port numbers and logic; in IPv6, to simplify the control plane, MLD was designed as an ICMPv6 message type.

This is a crucial mindset shift: in your packet sniffer, the layer above an MLD packet will always show ICMPv6.


Version Wars: MLDv1 vs. MLDv2

Don't be fooled by the names—this isn't just a simple "upgrade." If you force MLD to version 1 in your kernel configuration, you lose a very powerful feature.

MLDv1: The Old "One-Size-Fits-All" Mode

MLDv1 (based on RFC 2710) was copied from IGMPv2. It supports ASM (Any-Source Multicast). It's like telling the post office, "I want to subscribe to Tech Magazine." The post office doesn't ask which reporter's articles you want—as long as it's that magazine, whether it's written by Alice or Bob, it all gets stuffed into your mailbox.

This was fine in early networks, but as spam traffic and security issues emerged, this "accept-everything" approach proved too coarse.

MLDv2: The Precise "A La Carte" Mode

MLDv2 (based on RFC 3810) is the current standard (since 2004). It introduced SSM (Source-Specific Multicast). Now you can tell the post office, "I want to subscribe to Tech Magazine, but only articles by Alice—throw Bob's away."

This is source filtering. It allows hosts to explicitly specify:

  • INCLUDE: I only want to listen to these specific sources.
  • EXCLUDE: I want to listen to everyone except these specific sources.

Later in this section, we'll use real code to demonstrate how both modes operate within the kernel.


Rolling Up Our Sleeves: Joining and Leaving Multicast Groups

In the Linux kernel, joining a multicast group follows two completely different paths: one is initiated by the kernel itself (e.g., for protocol operation), and the other is requested by a user-space process via a Socket.

Path A: Kernel-Initiated Automatic Joins (ipv6_dev_mc_inc)

When a network interface comes up, the kernel doesn't just sit there idle. It must immediately join some "basic" multicast groups; otherwise, even Neighbor Discovery Protocol (NDP) won't work. This happens in ipv6_add_dev():

static struct inet6_dev *ipv6_add_dev(struct net_device *dev) {
...
/* 加入接口本地所有节点组播组 (ff01::1) */
ipv6_dev_mc_inc(dev, &in6addr_interfacelocal_allnodes);

/* 加入链路本地所有节点组播组 (ff02::1) */
ipv6_dev_mc_inc(dev, &in6addr_linklocal_allnodes);
...
}

This is mandatory. As soon as any IPv6 device comes alive, it must be listening on these two "loudspeaker" channels.

Router Privileges

If you enable forwarding (/proc/sys/net/ipv6/conf/all/forwarding set to 1), the kernel treats you as a router. Routers have more responsibilities and must join additional multicast groups:

  1. ff02::2 (Link-Local All Routers)
  2. ff01::2 (Interface-Local All Routers)
  3. ff05::2 (Site-Local All Routers)

This logic is clearly laid out in dev_forward_change():

static void dev_forward_change(struct inet6_dev *idev)
{
struct net_device *dev;
...
dev = idev->dev;
...
if (dev->flags & IFF_MULTICAST) {
if (idev->cnf.forwarding) {
// 变成路由器:加入这仨组
ipv6_dev_mc_inc(dev, &in6addr_linklocal_allrouters);
ipv6_dev_mc_inc(dev, &in6addr_interfacelocal_allrouters);
ipv6_dev_mc_inc(dev, &in6addr_sitelocal_allrouters);
} else {
// 变回主机:退出这仨组
ipv6_dev_mc_dec(dev, &in6addr_linklocal_allrouters);
ipv6_dev_mc_dec(dev, &in6addr_interfacelocal_allrouters);
ipv6_dev_mc_dec(dev, &in6addr_sitelocal_allrouters);
}
}
...
}

⚠️ Pitfall Warning People often run experiments in a VM and find that Router Advertisements (RAs) are never received. Upon investigation, they discover that forwarding was accidentally enabled, causing the VM to enter "router mode." As a result, it stops responding to host RAs, and in some configurations, even stops sending Router Solicitations (RS). This kind of role-switching error is often hidden right in this dev_forward_change logic.


Path B: User-Space Requests (ipv6_sock_mc_join)

This is the path developers interact with most. If your program wants to listen to a ff02::113 video stream, you have to tell the kernel.

The typical code pattern looks like this:

int sockd;
struct ipv6_mreq mcgroup;
struct addrinfo *results;

// 1. 填好组播地址 (假设 results 已经存好了地址)
memcpy( &(mcgroup.ipv6mr_multiaddr),
&(((struct sockaddr_in6 *) results->ai_addr)->sin6_addr),
sizeof(struct in6_addr));

// 2. 指定网卡 (通过 ifindex,比如 eth0 通常是 2)
mcgroup.ipv6mr_interface = 3;

// 3. 创建 Socket 并发起请求
sockd = socket(AF_INET6, SOCK_DGRAM, 0);
status = setsockopt(sockd, IPPROTO_IPV6, IPV6_JOIN_GROUP,
&mcgroup, sizeof(mcgroup));

When you call this setsockopt, the kernel does two things:

  1. Local Record: Adds this Socket to the subscription list for that multicast group.
  2. Send a "Notification": Transmits an MLDv2 Multicast Listener Report message outward.

Who Receives This "Notification"?

Note that the destination address of this Report message is not the multicast address you joined (e.g., ff05::9), but rather ff02::16. ff02::16 is a channel reserved exclusively for "all MLDv2-capable routers." Only routers listen on this channel; regular hosts completely ignore it.

What Does the Packet Look Like?

This packet carries two important markers:

  • Hop-by-Hop Options Header: Contains a Router Alert option. This is like a red stamp on an envelope, telling every router along the way: "Don't just forward me—stop and look!"
  • ICMPv6 Type 143: Indicates that this is a ICMPV6_MLD2_REPORT.

Figure 8-3 (Figure 8-3: MLDv2 Multicast Listener Report packet structure)

If you want to leave the group, calling IPV6_DROP_MEMBERSHIP (or simply closing the Socket) causes the kernel to send the corresponding Leave message (or stay silent and rely on a timeout mechanism, depending on the MLD version).


Diving into the MLDv2 Packet: The mld2_report Structure

Since we're sending Reports, let's look at what's inside them. The kernel uses struct mld2_report to describe it:

struct mld2_report {
struct icmp6_hdr mld2r_hdr; // ICMPv6 头部
struct mld2_grec mld2r_grec[0]; // 变长数组,存放具体记录
};

The key here is mld2_grec (Group Record), which describes exactly which group is involved, which source addresses are included, and how to handle them:

struct mld2_grec {
__u8 grec_type; // 记录类型 (是 INCLUDE 还是 EXCLUDE?)
__u8 grec_auxwords; // 辅助数据长度 (通常为 0)
__be16 grec_nsrcs; // 源地址的数量
struct in6_addr grec_mca; // 组播地址 (比如 ff05::9)
struct in6_addr grec_src[0]; // 源地址列表 (变长)
};

Here's an interesting detail:

  • For a normal join (without filtering), grec_nsrcs is 0, and grec_type is fairly simple.
  • If source filtering (SSM) is enabled, the grec_src array will be filled with specific IPs, such as 2000::1 and 2000::2. When the router sees this list, it knows: "Ah, this host only wants to listen to these two sources."

Advanced: Source Filtering

Since we've brought up source filtering, let's discuss how to actually use it. This is MLDv2's killer feature compared to v1.

Single-Source Filtering: MCAST_JOIN_SOURCE_GROUP

Suppose you only want to listen to the ff05::9 multicast stream from 2000::1.

You need to use the struct group_source_req structure, which adds a gsr_source field compared to the earlier ipv6_mreq:

int sockd;
struct group_source_req mreq;
struct addrinfo *results1; // 目标组播地址
struct addrinfo *results2; // 允许的源地址

// 1. 设置组播地址
memcpy(&(mreq.gsr_group), results1->ai_addr, sizeof(struct sockaddr_in6));

// 2. 设置允许的源地址 (关键!)
memcpy(&(mreq.gsr_source), results2->ai_addr, sizeof(struct sockaddr_in6));

// 3. 设置网卡
mreq.gsr_interface = 3;

sockd = socket(AF_INET6, SOCK_DGRAM, 0);
setsockopt(sockd, IPPROTO_IPV6, MCAST_JOIN_SOURCE_GROUP, &mreq, sizeof(mreq));

Once you execute this line of code, the kernel sends a Report where grec_type tells the router: "I want to join this group, but I'll only accept data from this single source."

Multi-Source Filtering: MCAST_MSFILTER

If you have a blacklist or whitelist with multiple IPs, calling JOIN_SOURCE one by one is simply too slow. In this case, you can use MCAST_MSFILTER to set them all at once.

The user-space definition looks like this:

struct group_filter {
uint32_t gf_interface; // 网卡索引
struct sockaddr_storage gf_group; // 组播地址
uint32_t gf_fmode; // 过滤模式:INCLUDE 或 EXCLUDE
uint32_t gf_numsrc; // 源地址数量
struct sockaddr_storage gf_slist[1]; // 源地址列表(变长)
};

Scenario 1: Whitelist (INCLUDE)

We want to listen to ff05::9, but only allow traffic from 2000::1, 2000::2, and 2000::3.

struct group_filter filter;
struct sockaddr_in6 *psin6;

filter.gf_interface = 2; // eth0
filter.gf_fmode = MCAST_INCLUDE; // 模式设为 INCLUDE
filter.gf_numsrc = 3; // 三个源

// 设置组地址
psin6 = (struct sockaddr_in6 *)&filter.gf_group;
psin6->sin6_family = AF_INET6;
inet_pton(PF_INET6, "ffff::9", &psin6->sin6_addr);

// 填充源列表
psin6 = (struct sockaddr_in6 *)&filter.gf_slist[0];
psin6->sin6_family = AF_INET6;
inet_pton(PF_INET6, "2000::1", &psin6->sin6_addr);
// ... (依次设置 2000::2, 2000::3)

// 发送给内核
sockd[0] = socket(AF_INET6, SOCK_DGRAM, 0);
setsockopt(sockd[0], IPPROTO_IPV6, MCAST_MSFILTER, &filter, GROUP_FILTER_SIZE(filter.gf_numsrc));

This triggers a Report where grec_type is MLD2_CHANGE_TO_INCLUDE (3), carrying 3 source addresses.

Scenario 2: Blacklist (EXCLUDE)

We want to listen to ff05::9, but reject traffic from 2001::1 and 2001::2.

The code logic is the same, just changing gf_fmode to MCAST_EXCLUDE and swapping the source list for those two blacklisted IPs.

This triggers MLD2_CHANGE_TO_EXCLUDE (4).

Verifying with /proc/net/mcfilter6

This is the most handy tool for debugging. Don't just trust the code—check what the kernel's ledger actually says:

cat /proc/net/mcfilter6
Idx Device Multicast Address Source Address INC EXC
2 eth0 ffff::9 2000::1 1 0
2 eth0 ffff::9 2000::2 1 0
2 eth0 ffff::9 2000::3 1 0
2 eth0 ffff::9 2001::1 0 1
2 eth0 ffff::9 2001::2 0 1

It's written clearly right there: the first three lines are INCLUDE (INC=1), and the last two are EXCLUDE (EXC=1). If your video stream never arrives, check this table first. See if an IP you intended to whitelist was recorded by the kernel as EXCLUDE, or wasn't recorded at all.


What Is the Router Doing?

We've been looking at this entirely from the "audience" perspective. But what about the "host" (the router)?

When a router starts up (for example, the mld6igmp daemon from the XORP project):

  1. It joins ff02::16 (to listen to all Reports).
  2. It periodically sends Multicast Listener Query messages (ICMPv6 Type 130) to ff02::1.
  3. Upon receiving a Report, it updates its state table, noting that "someone on eth0 wants to listen to ff05::9."
  4. When multicast packets pass through, it checks the state table to decide whether to forward them out that interface.

Query Details When a host receives a Query, the kernel's handler is igmp6_event_query(). Here's a small detail: how does this function distinguish between an MLDv1 and an MLDv2 query? The answer: check the length.

  • MLDv1 Query: Fixed length of 24 bytes.
  • MLDv2 Query: Minimum length of 28 bytes.

Don't underestimate this check. If the versions don't match, the host might reply with a v1 Report, causing v2-specific source filtering information to be lost and breaking the construction of the entire multicast tree.

Forced Downgrade: force_mld_version

If you're in a legacy network or doing compatibility testing, you can force the kernel to speak the v1 dialect:

echo 1 > /proc/sys/net/ipv6/conf/all/force_mld_version

Once you do this, your setsockopt(..., MCAST_JOIN_SOURCE_GROUP, ...) will become ineffective. Because v1 doesn't understand source addresses at all. The kernel will ignore the source filtering parameters and treat you as a regular ASM client.


Chapter Echo

Returning to the question we posed at the beginning of this section: how does a router know who the "members" are?

The answer is now clear: the router maintains a dynamic roster. This roster isn't filled out manually by an administrator; it's built by hosts continuously "speaking up" via the MLD protocol. In the IPv4 era, IGMP was an independent protocol; in IPv6, MLD became part of ICMPv6, leveraging the Router Alert in the Hop-by-Hop extension header to ensure that every router must stop and process these reports.

This reflects a philosophy in IPv6 design: unification and simplification of the control plane. While this unification reduces protocol complexity, it also requires us to broaden our "vision" during debugging. We can't just look at multicast packets—we also have to examine the ICMPv6 message types nested inside them.

In the next chapter, we'll step out of the IP layer and head toward that famous "network filter"—Netfilter. There, we'll see how these multicast packets are intercepted, modified, or allowed through by firewall rules. That is the most treacherous stretch of road in the kernel network stack.