7.4 NDISC Protocol (IPv6)
In the previous section, we finished discussing IPv4's ARP. To be fair, ARP does exactly what it's supposed to do: it's simple, direct, and effective. But it also has inherent flaws—there are no security mechanisms, and anyone can impersonate the gateway (what we commonly call ARP spoofing).
In the IPv6 era, this "shout-and-anyone-can-respond" mechanism simply doesn't cut it. We need a more rigorous and feature-rich protocol.
This is NDISC (Neighbor Discovery for IPv6), defined in RFC 4861.
In this section, we'll see how IPv6 uses NDISC to replace ARP. You'll find that while the core task is still "turning an IP into a MAC," NDISC does much more: it handles not only address resolution but also router discovery, prefix discovery, and Duplicate Address Detection (DAD).
7.4.1 Duplicate Address Detection (DAD)
In the IPv4 era, if two machines on a LAN were configured with the same IP, the consequences were usually unpredictable—sometimes both could communicate, sometimes neither could, depending on luck. But in IPv6, we absolutely cannot tolerate this kind of ambiguity.
DAD (Duplicate Address Detection) is a mandatory step in the IPv6 address configuration process (whereas in IPv4, it was merely optional).
The logic is simple: when you want to configure an IPv6 address, you can't just use it right away. You must first ask, "Is anyone using this address?" If no one responds, only then can you mark it as a permanent address.
The specific flow looks like this:
- Generate a Link-Local address: The host first generates a Link-Local address starting with
FE80. - Mark as Tentative: At this point, the address is in the
IFA_F_TENTATIVEstate. The kernel can only use it to send NDISC-related messages, not application traffic. - Initiate DAD: Call
addrconf_dad_start()(located atnet/ipv6/addrconf.c). - Send an NS message: Send a special Neighbor Solicitation message.
- Target Address: Fill in the IP you want to use (i.e., the tentative address).
- Source Address: Fill in all zeros (the unspecified address
::).
- Wait: If no response is received within the specified time, it means the address is truly unused.
- Confirm: The state changes from
IFA_F_TENTATIVEtoIFA_F_PERMANENT, and the address officially takes effect.
Here's a detail: if someone replies with an NA during DAD, it means there's an address conflict. DAD fails, and the kernel will report an error and disable the address.
⚠️ About Optimistic DAD
Since DAD requires waiting, doesn't that mean the network will "stall" for a bit? It does. To solve this problem, RFC 4429 introduced Optimistic DAD (kernel configuration CONFIG_IPV6_OPTIMISTIC_DAD).
When enabled, the kernel doesn't wait for DAD to finish before marking the address as "optimistic" (IFA_F_OPTIMISTIC), allowing you to start using it right away. If a conflict is discovered later, it rolls back. This is a "use now, pay later" strategy—you assume the risk.
7.4.2 NDISC Infrastructure: nd_tbl
Just like IPv4 uses arp_tbl, IPv6 also has its own neighbor table, called nd_tbl.
Let's look at its definition (net/ipv6/ndisc.c):
struct neigh_table nd_tbl = {
.family = AF_INET6,
.key_len = sizeof(struct in6_addr),
.hash = ndisc_hash,
.constructor = ndisc_constructor,
.pconstructor = pndisc_constructor,
.pdestructor = pndisc_destructor,
.proxy_redo = pndisc_redo,
.id = "ndisc_cache",
.parms = {
.tbl = &nd_tbl,
.base_reachable_time = ND_REACHABLE_TIME,
.retrans_time = ND_RETRANS_TIMER,
.gc_staletime = 60 * HZ,
.reachable_time = ND_REACHABLE_TIME,
.delay_probe_time = 5 * HZ,
.queue_len_bytes = 64*1024,
.ucast_probes = 3,
.mcast_probes = 3,
.anycast_delay = 1 * HZ,
.proxy_delay = (8 * HZ) / 10,
.proxy_qlen = 64,
},
.gc_interval = 30 * HZ,
.gc_thresh1 = 128,
.gc_thresh2 = 512,
.gc_thresh3 = 1024,
};
You'll notice that this structure is almost identical to arp_tbl.
gc_thresh1/2/3: Garbage collection thresholds, with default values the same as the ARP table (128, 512, 1024).parms: Defines behavioral parameters such as timeouts and retry counts.
NDISC is implemented based on ICMPv6 messages. It defines 5 core message types:
#define NDISC_ROUTER_SOLICITATION 133
#define NDISC_ROUTER_ADVERTISEMENT 134
#define NDISC_NEIGHBOUR_SOLICITATION 135
#define NDISC_NEIGHBOUR_ADVERTISEMENT 136
#define NDISC_REDIRECT 137
Note: ICMPv6 Type values between 128-255 are "informational messages," while 0-127 are "error messages." All NDISC messages are informational.
Among these 5 message types, RS/RA and Redirect are primarily related to routing, so we'll save those for Chapter 8. In this section, we only care about NS (135) and NA (136), which correspond to ARP Request and Reply.
In the kernel, the receive path for NDISC messages looks like this:
- A network packet arrives at
icmpv6_rcv(). icmpv6_rcv()sees that the Type is one of the 5 above, and passes it tondisc_rcv()for processing.
As for neigh_ops, NDISC also has a corresponding set of logic:
ndisc_direct_ops: If the NIC doesn't need a hardware header (e.g.,header_opsis NULL), send it directly by callingneigh_direct_output().ndisc_generic_ops: Ifheader_ops->cacheis NULL.ndisc_hh_ops: Ifheader_ops->cacheexists, cache the hardware header.
This logic is completely consistent with the ARP side, just under different names.
7.4.3 Sending Neighbor Solicitation (NS)
Now, your IPv6 packet needs to go out, but you don't know the target's MAC address. What do you do?
Just like in IPv4, this process happens in ip6_finish_output2():
static int ip6_finish_output2(struct sk_buff *skb)
{
struct dst_entry *dst = skb_dst(skb);
struct net_device *dev = dst->dev;
struct neighbour *neigh;
struct in6_addr *nexthop;
int ret;
. . .
// 1. 找下一跳
nexthop = rt6_nexthop((struct rt6_info *)dst, &ipv6_hdr(skb)->daddr);
// 2. 查邻居表
neigh = __ipv6_neigh_lookup_noref(dst->dev, nexthop);
// 3. 如果没找到,创建一个
if (unlikely(!neigh))
neigh = __neigh_create(&nd_tbl, nexthop, dst->dev, false);
// 4. 发送(如果状态不对,会触发解析)
if (!IS_ERR(neigh)) {
ret = dst_neigh_output(dst, neigh, skb);
. . .
If the neighbor state is not NUD_REACHABLE, the kernel triggers neigh_probe(), which in turn calls neigh->ops->solicit.
For NDISC, this callback is ndisc_solicit().
Let's look at its implementation (net/ipv6/ndisc.c):
static void ndisc_solicit(struct neighbour *neigh, struct sk_buff *skb)
{
struct in6_addr *saddr = NULL;
struct in6_addr mcaddr;
struct net_device *dev = neigh->dev;
struct in6_addr *target = (struct in6_addr *)&neigh->primary_key;
int probes = atomic_read(&neigh->probes);
// 如果有现成的源地址,就用它
if (skb && ipv6_chk_addr(dev_net(dev), &ipv6_hdr(skb)->saddr, dev, 1))
saddr = &ipv6_hdr(skb)->saddr;
// 决定是单播探测还是组播探测
if ((probes -= neigh->parms->ucast_probes) < 0) {
// 单播探测:目标就是对方的 IP
if (!(neigh->nud_state & NUD_VALID)) {
ND_PRINTK(1, dbg, "%s: trying to ucast probe in NUD_INVALID: %pI6\n",
__func__, target);
}
ndisc_send_ns(dev, neigh, target, target, saddr);
} else if ((probes -= neigh->parms->app_probes) < 0) {
// 用户态 ARP 守护进程相关(CONFIG_ARPD)
#ifdef CONFIG_ARPD
neigh_app_ns(neigh);
#endif
} else {
// 组播探测:目标是对应的 Solicited Node 组播地址
addrconf_addr_solict_mult(target, &mcaddr);
ndisc_send_ns(dev, NULL, target, &mcaddr, saddr);
}
}
The core logic of this code is:
- First, try to unicast the NS (if the other side has a previous record but the state is just Stale, asking them directly is the fastest).
- If the unicast attempts are exhausted, or there's no record at all, multicast the NS.
Ultimately, both paths call ndisc_send_ns() to construct and send the ICMPv6 packet.
The NDISC message structure nd_msg is quite simple:
struct nd_msg {
struct icmp6hdr icmph;
struct in6_addr target;
__u8 opt[0];
};
- For NS,
icmph.icmp6_typeis set toNDISC_NEIGHBOUR_SOLICITATION. - For NA,
icmph.icmp6_typeis set toNDISC_NEIGHBOUR_ADVERTISEMENT.
There's a big gotcha with NA messages: they come with a bunch of flags, defined in a union within icmp6hdr:
struct icmpv6_nd_advt {
#if defined(__LITTLE_ENDIAN_BITFIELD)
__u32 reserved:5,
override:1, // 强制更新缓存
solicited:1, // 这是一个响应
router:1, // 发送者是路由器
reserved2:24;
#endif
} u_nd_advt;
These three flags are crucial:
- Router: The sender is a router; the receiver should update its default router list.
- Solicited: Set to 1 to mean "I'm responding to your request," not an unsolicited broadcast. The receiver will typically set the state to
REACHABLEupon receiving this. - Override: Set to 1 to mean "no matter what you have cached, use mine," forcing an L2 address update.
Let's take a look at the core logic of ndisc_send_ns():
void ndisc_send_ns(struct net_device *dev, struct neighbour *neigh,
const struct in6_addr *solicit,
const struct in6_addr *daddr, const struct in6_addr *saddr)
{
struct sk_buff *skb;
struct in6_addr addr_buf;
int inc_opt = dev->addr_len;
int optlen = 0;
struct nd_msg *msg;
// 如果没指定源地址,找个 Link-Local 地址凑合
if (saddr == NULL) {
if (ipv6_get_lladdr(dev, &addr_buf,
(IFA_F_TENTATIVE|IFA_F_OPTIMISTIC)))
return;
saddr = &addr_buf;
}
// 如果源地址是全0(DAD的情况),就不带 Source Link-Layer Option
if (ipv6_addr_any(saddr))
inc_opt = 0;
if (inc_opt)
optlen += ndisc_opt_addr_space(dev);
skb = ndisc_alloc_skb(dev, sizeof(*msg) + optlen);
if (!skb)
return;
// 构造 ICMPv6 头部
msg = (struct nd_msg *)skb_put(skb, sizeof(*msg));
*msg = (struct nd_msg) {
.icmph = {
.icmp6_type = NDISC_NEIGHBOUR_SOLICITATION,
},
.target = *solicit,
};
// 填充 Source Link-Layer Address Option
if (inc_opt)
ndisc_fill_addr_option(skb, ND_OPT_SOURCE_LL_ADDR,
dev->dev_addr);
ndisc_send_skb(skb, daddr, saddr);
}
Correspondingly, the one that sends NA is ndisc_send_na():
static void ndisc_send_na(struct net_device *dev, struct neighbour *neigh,
const struct in6_addr *daddr,
const struct in6_addr *solicited_addr,
bool router, bool solicited, bool override, bool inc_opt)
{
...
msg = (struct nd_msg *)skb_put(skb, sizeof(*msg));
*msg = (struct nd_msg) {
.icmph = {
.icmp6_type = NDISC_NEIGHBOUR_ADVERTISEMENT,
.icmp6_router = router,
.icmp6_solicited = solicited,
.icmp6_override = override,
},
.target = *solicited_addr,
};
// 填充 Target Link-Layer Address Option(告诉对方我的 MAC)
if (inc_opt)
ndisc_fill_addr_option(skb, ND_OPT_TARGET_LL_ADDR,
dev->dev_addr);
...
}
7.4.4 Receiving and Processing NS and NA
Now let's switch to the receiving perspective. A packet comes in, and ndisc_rcv() orchestrates the overall handling.
The very first thing it does is critical—security checks.
int ndisc_rcv(struct sk_buff *skb)
{
struct nd_msg *msg;
if (skb_linearize(skb))
return 0;
msg = (struct nd_msg *)skb_transport_header(skb);
__skb_push(skb, skb->data - skb_transport_header(skb));
// 检查 1:Hop Limit 必须是 255
if (ipv6_hdr(skb)->hop_limit != 255) {
ND_PRINTK(2, warn, "NDISC: invalid hop-limit: %d\n",
ipv6_hdr(skb)->hop_limit);
return 0;
}
// 检查 2:ICMPv6 Code 必须是 0
if (msg->icmph.icmp6_code != 0) {
ND_PRINTK(2, warn, "NDISC: invalid ICMPv6 code: %d\n",
msg->icmph.icmp6_code);
return 0;
}
...
}
Why must the Hop Limit be 255? Because routers decrement the Hop Limit by 1 when forwarding packets. If the received packet has a Hop Limit of 255, it means it came from the same link and wasn't forwarded by any routers. This effectively prevents remote attackers from forging NDISC messages to attack your LAN. This is mandatory per RFC 4861.
Following that is a large switch that dispatches to different handler functions:
switch (msg->icmph.icmp6_type) {
case NDISC_NEIGHBOUR_SOLICITATION:
ndisc_recv_ns(skb);
break;
case NDISC_NEIGHBOUR_ADVERTISEMENT:
ndisc_recv_na(skb);
break;
...
}
Handling NS (ndisc_recv_ns)
This is one of the most complex methods in the entire neighbor subsystem because it has to handle three scenarios:
- The target is my own address: Someone is asking "Whose IP is this?", and I need to reply with an NA.
- DAD conflict detection: Someone is saying "I'm doing DAD, the target is IP X," and I'm also using IP X. This means a collision has occurred, and it must be handled.
- Proxy NDP: Someone is asking about someone else, but I have proxy configured, so I answer on their behalf.
Let's look at it in parts (net/ipv6/ndisc.c):
Step 1: Determine if it's DAD or a normal query
static void ndisc_recv_ns(struct sk_buff *skb)
{
...
// 如果源地址是全0 (::),说明这是 DAD 探测
int dad = ipv6_addr_any(saddr);
// 各种合法性检查(包长、目标是否组播、DAD 包的目的地是否正确等)
if (skb->len < sizeof(struct nd_msg)) { ... return; }
if (ipv6_addr_is_multicast(&msg->target)) { ... return; } // 目标不能是组播
if (dad && !ipv6_addr_is_solict_mult(daddr)) { ... return; } // DAD 必须发到 Solicited 组播
...
}
Step 2: Check if the target address belongs to me
ifp = ipv6_get_ifaddr(dev_net(dev), &msg->target, dev, 1);
if (ifp) {
// 情况 A:撞车了!
if (ifp->flags & (IFA_F_TENTATIVE|IFA_F_OPTIMISTIC)) {
if (dad) {
// 我也在做 DAD,有人发了同样的包 -> DAD 失败
addrconf_dad_failure(ifp);
return;
} else {
// 对方在正常请求,但我还在 Tentative 状态
// 如果不是 Optimistic,就忽略
if (!(ifp->flags & IFA_F_OPTIMISTIC))
goto out;
}
}
// 目标在我身上,准备回复
idev = ifp->idev;
} else {
// 目标不在我身上,检查是否需要 Proxy
...
}
Step 3: Reply with NA
if (dad) {
// 针对普通 NS 的 DAD 回复(目标地址填自己,Flag 设 Router)
ndisc_send_na(dev, NULL, &in6addr_linklocal_allnodes, &msg->target,
!!is_router, false, (ifp != NULL), true);
goto out;
}
...
// 普通回复:顺便更新一下邻居表(把发 NS 的人记下来)
neigh = __neigh_lookup(&nd_tbl, saddr, dev, !inc || lladdr || !dev->addr_len);
if (neigh) {
// 状态设为 STALE(因为我不确定对方真的在不在,只是看到了包)
neigh_update(neigh, lladdr, NUD_STALE,
NEIGH_UPDATE_F_WEAK_OVERRIDE|
NEIGH_UPDATE_F_OVERRIDE);
...
// 发送 NA 回复
ndisc_send_na(dev, neigh, saddr, &msg->target,
!!is_router,
true, (ifp != NULL && inc), inc);
...
}
Handling NA (ndisc_recv_na)
After we send an NS, we'll receive the other side's NA. Let's see how the kernel handles it (net/ipv6/ndisc.c):
Step 1: Validity checks
static void ndisc_recv_na(struct sk_buff *skb)
{
...
// 目标地址不能是组播
if (ipv6_addr_is_multicast(&msg->target)) { ... return; }
// 如果是 Solicited NA,目的地址不能是组播(Solicited NA 必须单播)
if (ipv6_addr_is_multicast(daddr) &&
msg->icmph.icmp6_solicited) { ... return; }
// 检查是否有人抢了我的 IP
ifp = ipv6_get_ifaddr(dev_net(dev), &msg->target, dev, 1);
if (ifp) {
if (skb->pkt_type != PACKET_LOOPBACK
&& (ifp->flags & IFA_F_TENTATIVE)) {
// 收到别人对我 Tentative 地址的 NA -> DAD 失败
addrconf_dad_failure(ifp);
return;
}
...
if (skb->pkt_type != PACKET_LOOPBACK)
ND_PRINTK(1, warn,
"NA: someone advertises our address %pI6 on %s!\n",
&ifp->addr, ifp->idev->dev->name);
...
}
Step 2: Update the neighbor table
neigh = neigh_lookup(&nd_tbl, &msg->target, dev);
if (neigh) {
...
// 根据 NA 的 Flag 决定如何更新
// 1. Solicited=1 -> 状态设为 REACHABLE (邻居在线)
// 2. Solicited=0 -> 状态设为 STALE (可能是 Gratuitous ARP 类似的主动通告)
// 3. Override=1 -> 强制更新 MAC
neigh_update(neigh, lladdr,
msg->icmph.icmp6_solicited ? NUD_REACHABLE : NUD_STALE,
NEIGH_UPDATE_F_WEAK_OVERRIDE|
(msg->icmph.icmp6_override ? NEIGH_UPDATE_F_OVERRIDE : 0)|
NEIGH_UPDATE_F_OVERRIDE_ISROUTER|
(msg->icmph.icmp6_router ? NEIGH_UPDATE_F_ISROUTER : 0));
...
}
}
This logic is very similar to ARP's arp_process, just with added handling details for the Router and Override flags.
7.4.5 Summary
In this section, we broke down IPv6's NDISC protocol.
Compared to ARP, it is undoubtedly more complex:
- Richer messaging: Not just NS/NA, but also RS/RA/Redirect.
- Stronger security: Mandatory Hop Limit 255 checks prevent remote attacks.
- More precise semantics: Through the Solicited/Override/Router flags, it precisely controls the neighbor state machine's behavior (
NUD_REACHABLEvsNUD_STALE). - More complete functionality: A built-in DAD mechanism solves the IP address conflict problem at its root.
But if you strip away these complex fields and options, you'll find that its core behavior is not fundamentally different from ARP: send a request, receive a response, cache the result.
Once you understand ARP, looking at NDISC you'll realize you're just looking at an "enhanced remaster" of the same story. In the next chapter, we'll take this neighbor discovery knowledge and dive deeper into the IPv6 subsystem—seeing how auto-configuration and routing leverage these foundations.