IPv6 Killed ARP: Watching Neighbor Discovery Resolve a MAC Address, Packet by Packet

This post is part of Protocol Lab, a free, hands-on series for learning networking protocols by building and breaking them in a container lab. All the lab material — topologies, configs, and scripts — lives in the repo: github.com/pathvector-studio/protocol-lab.

To send a packet to a neighbor on the same link, a host needs that neighbor's MAC address. On IPv4 that job is ARP. On IPv6 it is Neighbor Discovery (NDP) — the same idea, but done with ICMPv6 and multicast instead of broadcast. In this lab we watch the resolution happen on the wire.

Reading guide: rfc-notes/ndp-neighbor-discovery.md

Prerequisite: TCP Lab 07: Handshake and Teardown (reading captures)

Expected time: 40–55 minutes.

The Goal

Two IPv6 nodes share a link (2001:db8:23::1 and ::2), and we trigger address resolution from scratch:

  • With the neighbor cache cleared, node-a pings node-b.
  • node-a sends a Neighbor Solicitation (ICMPv6 type 135) to node-b's solicited-node multicast address — "who has 2001:db8:23::2?"
  • node-b replies with a Neighbor Advertisement (type 136) carrying its MAC.
  • node-a's neighbor table now maps 2001:db8:23::2 → MAC.

By the end, you should be able to compare ARP and NDP:

ARP (IPv4) NDP (IPv6)
Carried by its own EtherType ICMPv6
"who has X?" sent to broadcast (everyone) solicited-node multicast (few)
Request / Reply ARP request / reply Neighbor Solicitation / Advertisement
Cache ARP table neighbor cache

What You Will Learn

  • That IPv6 replaces ARP with Neighbor Discovery, built on ICMPv6.
  • What a Neighbor Solicitation and Neighbor Advertisement are.
  • What a solicited-node multicast address is (ff02::1:ffXX:XXXX) and why it is more targeted than broadcast.
  • Where the neighbor cache is (ip -6 neigh) and what states it has.
  • That NDP also does other jobs (router discovery, DAD) — here we focus on address resolution.

This lab does not cover:

  • Router Advertisement / SLAAC auto-configuration (needs a router advertising a prefix).
  • Duplicate Address Detection (DAD) in depth, or Secure Neighbor Discovery (SEND).
  • IPv6 addressing/subnetting beyond what the lab uses.

Where to Read in the RFCs

Reference What to focus on
RFC 4861 §4.3–4.4 Message formats for Neighbor Solicitation / Advertisement
RFC 4861 §7.2 The address-resolution flow (NS → NA, caching)
RFC 4291 §2.7.1 How a solicited-node multicast address is constructed
RFC 4861 §7.3 Neighbor cache states (INCOMPLETE / REACHABLE / STALE)

The Big Picture

Two IPv6 nodes are connected by a single link.

node-a 2001:db8:23::1/64 ==== eth1/eth1 ==== node-b 2001:db8:23::2/64

Clear node-a's neighbor cache and ping — NDP address resolution kicks in.

sequenceDiagram
  participant A as node-a (::1)
  participant M as solicited-node multicast<br/>ff02::1:ff00:2
  participant B as node-b (::2)

  Note over A: cache cleared; wants ::2's MAC
  A->>M: Neighbor Solicitation "who has 2001:db8:23::2?"
  Note over B: B listens on ff02::1:ff00:2
  B->>A: Neighbor Advertisement "::2 is at aa:...:cb"
  Note over A: neighbor cache: ::2 -> aa:...:cb (REACHABLE)

Note: 2001:db8::/32 is the IPv6 documentation prefix (RFC 3849), so nothing in this lab touches the real internet.

What You Need

Recommended environment:

  • Linux / WSL2 / a Linux VM (IPv6 enabled — the default on any recent Linux)
  • Docker
  • containerlab

Images used:

  • nicolaka/netshoot:latest — bundles ip, ping6, and tcpdump (with ICMPv6 decoding).

No additional images are required.

Running the Lab

The quick path, which deploys, verifies, and tears down for you:

./scripts/labctl.sh run ndp-23

Or step through it manually:

1. Move into the working directory

cd protocol-lab/examples/ndp-23

2. Deploy

sudo containerlab deploy -t ndp-23.clab.yml
docker exec clab-ndp-23-node-a ip -6 addr show eth1   # 2001:db8:23::1 plus link-local fe80::

3. Clear the neighbor cache and watch the resolution

docker exec clab-ndp-23-node-a ip -6 neigh flush all
docker exec -d clab-ndp-23-node-a tcpdump -i eth1 -n -e "icmp6"
docker exec clab-ndp-23-node-a ping6 -c2 2001:db8:23::2
docker exec clab-ndp-23-node-a pkill -INT tcpdump
docker exec clab-ndp-23-node-a tcpdump -n -e -vv -r /tmp/ndp.pcap

What to look for:

... > 33:33:ff:00:00:02 ... 2001:db8:23::1 > ff02::1:ff00:2: ICMP6, neighbor solicitation, who has 2001:db8:23::2
... 2001:db8:23::2 > 2001:db8:23::1: ICMP6, neighbor advertisement, tgt is 2001:db8:23::2, Flags [solicited, override]

4. Inspect the neighbor cache

docker exec clab-ndp-23-node-a ip -6 neigh show dev eth1
2001:db8:23::2 lladdr aa:c1:ab:36:6f:cb REACHABLE

::2 is now in the cache with its MAC. From here on the cache is used — no solicitation on every packet.

Expected Output

  • ping6 2001:db8:23::2 succeeds.
  • The capture shows a neighbor solicitation (sent to ff02::1:ff00:2) and a neighbor advertisement (carrying the MAC).
  • ip -6 neigh shows 2001:db8:23::2 lladdr <MAC> REACHABLE.

Why It Works

An IP address identifies which host, but actually delivering a frame on the same link requires a MAC address. This "IP → MAC" resolution is ARP's job on IPv4 and NDP's job on IPv6.

  • It rides on ICMPv6. NDP is not a standalone protocol; it is a set of ICMPv6 messages (types 133–137). Neighbor Solicitation = 135, Neighbor Advertisement = 136. That makes it a natural part of IPv6 itself.
  • Solicited-node multicast. ARP broadcasts to everyone. NDP aims tighter: from the low-order 24 bits of the target address 2001:db8:23::2 it forms the solicited-node multicast address ff02::1:ff00:2 and sends the NS there. Only the very few hosts whose low 24 bits match are listening on that group — so unrelated hosts never get woken up. More efficient than broadcast.
  • NS → NA. The target (node-b) listens on its own solicited-node multicast address, and on receiving the NS replies with a Neighbor Advertisement carrying its MAC. Flags [solicited, override] means "this answers a request / it may overwrite an existing cache entry."
  • The neighbor cache. The learned "IP → MAC" mapping is cached (ip -6 neigh). Entries move through states such as INCOMPLETE (resolving) → REACHABLE (recently confirmed) → STALE (unused for a while). Subsequent traffic uses the cache instead of soliciting every time.
  • NDP's other jobs. Beyond address resolution, NDP also handles router discovery (RS/RA), prefix advertisement / SLAAC, and Duplicate Address Detection (DAD — sending an NS for your own address at assignment time to check for conflicts). This lab focuses on resolution, but it's all the same ICMPv6 framework.

The key insight: IPv6 does "IP → MAC" resolution with ICMPv6 and a tightly targeted multicast (solicited-node) — smarter than ARP's broadcast.

Common Pitfalls

  • Treating NDP as something totally different from ARP. The role is identical (IP → MAC); only the mechanism changed to ICMPv6 + multicast.
  • Assuming it's broadcast. IPv6 has no broadcast. It uses solicited-node multicast.
  • How the solicited-node multicast address is built. Append the low-order 24 bits of the target address to ff02::1:ff00:0/104.
  • Forgetting link-local. Every IPv6 interface also carries a fe80:: link-local address, and NDP works over link-local too.
  • Cache states. REACHABLE, STALE, and friends. Traffic can start even from STALE; the entry is re-confirmed when needed.
  • DAD's NS. The moment an address is assigned, an NS also goes out (duplicate detection). It can show up in your capture mixed in with the ping-triggered NS.

Cleanup

sudo containerlab destroy -t ndp-23.clab.yml --cleanup

If you used labctl.sh run ndp-23, the script runs destroy for you at the end.

Check Your Understanding

  1. Besides the IP address, what does a host need to deliver an IP packet on the same link?
  2. What is IPv6's equivalent of IPv4's ARP? What does it ride on?
  3. What address is a Neighbor Solicitation sent to? How does that differ from broadcast?
  4. How is a solicited-node multicast address constructed?
  5. What goes into the neighbor cache? What states does it have?
  6. Besides address resolution, what other jobs does NDP do? Name two.

References

Verified Run Log (2026-07-07)

This lab has been confirmed reproducible on real hardware.

Environment:

  • Ubuntu 26.04 LTS (kernel 7.0.0-27-generic, x86_64)
  • Docker 29.1.3
  • containerlab 0.77.0
  • node-a / node-b: nicolaka/netshoot:latest (ping6, tcpdump)

Running PATH="/tmp/pl-shim:$PATH" ./scripts/labctl.sh run ndp-23 performed deploy → verify → destroy, and verification.json returned "status": "verified".

Neighbor Solicitation → Advertisement (ARP, the IPv6 way)

$ docker exec clab-ndp-23-node-a tcpdump -n -e -vv -r ndp.pcap
aa:c1:ab:f1:f4:6f > 33:33:ff:00:00:02 ... 2001:db8:23::1 > ff02::1:ff00:2:
    ICMP6, neighbor solicitation, who has 2001:db8:23::2
aa:c1:ab:36:6f:cb > aa:c1:ab:f1:f4:6f ... 2001:db8:23::2 > 2001:db8:23::1:
    ICMP6, neighbor advertisement, tgt is 2001:db8:23::2, Flags [solicited, override]

node-a sends the NS to node-b's solicited-node multicast address (ff02::1:ff00:2, which is 33:33:ff:00:00:02 at layer 2), and node-b answers with an NA carrying its MAC. The difference from ARP: instead of broadcast, this is a multicast that reaches only the hosts whose low 24 bits match.

The neighbor cache after resolution

$ docker exec clab-ndp-23-node-a ip -6 neigh show dev eth1
2001:db8:23::2 lladdr aa:c1:ab:36:6f:cb REACHABLE

2001:db8:23::2 is in the cache with its MAC, state REACHABLE. Subsequent traffic uses the cache — no solicitation each time. IPv6 does its "IP → MAC" resolution with ICMPv6 and a targeted multicast: the successor to ARP.

Cleanup

containerlab destroy -t ndp-23.clab.yml --cleanup

That's Neighbor Discovery: same job as ARP, but carried by ICMPv6 and aimed at a solicited-node multicast group instead of shouting at the whole link.

Explore the full Protocol Lab series here: github.com/pathvector-studio/protocol-lab. If these labs are useful to you, please ⭐ star the repo on GitHub — it genuinely helps others find the project.

Next up, we'll look at what happens when a router joins the link — Router Advertisements and how IPv6 hosts configure themselves with SLAAC.

Read more