ARP and Neighbour Discovery
Once the routing subsystem has decided that a packet’s next hop is some IP address on a directly-attached link, one question remains before a frame can hit the wire: what link-layer (MAC) address belongs to that IP? On IPv4 the answer comes from the Address Resolution Protocol (ARP) — a broadcast “who has 192.0.2.5?” request and a unicast “192.0.2.5 is at aa:bb:cc:dd:ee:ff” reply, defined by RFC 826 and implemented in
net/ipv4/arp.c. On IPv6 the same job is done by Neighbour Discovery (ND), a richer suite of five ICMPv6 message types defined by RFC 4861 and implemented innet/ipv6/ndisc.c. Both feed the same in-kernel neighbour table (the structure historically called the “ARP cache”), whose generic reachability state machine is the subject of The Neighbour Subsystem. This note covers the protocols — the on-wire messages, gratuitous announcements, proxy resolution, and the spoofing weakness they share; the state machine that consumes their results is deferred to that sibling. (Verified against Linux 6.12 LTS, 2026-06-06.)
The routing decision that produces the next-hop IP this note resolves is IP Routing Decision and Forwarding; the layer that asks for the resolution is The IP Layer. This note is the L2 resolution that turns the routing layer’s “send to 192.0.2.5 via eth0” into an actual Ethernet destination address.
Mental Model — Filling In the Missing Address
Think of every outgoing frame as an envelope with two address lines that must be filled: a destination IP (already known, from routing) and a destination MAC (unknown, until resolved). ARP and ND are the lookup service that fills the second line. The service is demand-driven and cached: the first packet to a new neighbour stalls while a request is broadcast and a reply awaited; the answer is stored in the neighbour table so subsequent packets go straight out. Because the request must reach a host whose MAC is by definition unknown, it is sent to a broadcast (ARP) or a solicited-node multicast (ND) L2 address — the one address that is guaranteed to reach the target without already knowing its MAC.
sequenceDiagram participant H as Host A (wants to reach 192.0.2.5) participant L as Link (broadcast domain) participant T as Host B (owns 192.0.2.5) Note over H: routing says next-hop = 192.0.2.5 on eth0<br/>neighbour table has no MAC for it H->>L: ARP REQUEST (broadcast ff:ff:ff:ff:ff:ff)<br/>"who has 192.0.2.5? tell 192.0.2.1" L->>T: every host on link receives it Note over T: target recognises its own IP<br/>also caches A's IP→MAC from the request T->>H: ARP REPLY (unicast to A's MAC)<br/>"192.0.2.5 is at aa:bb:cc:dd:ee:ff" Note over H: cache B's MAC, mark REACHABLE<br/>frame now leaves with dst MAC filled in
An IPv4 ARP exchange. What it shows: resolution is one broadcast request and one unicast reply; the request itself also teaches the target the requester’s mapping (the target caches A’s address from B’s request, which is why a single round trip primes both ends). The insight to take: ARP is unauthenticated — the reply is believed on faith, and any host on the link can answer. ND on IPv6 follows the identical request/reply shape but rides on ICMPv6 and replaces broadcast with a per-target multicast group, which is more efficient but shares the same trust-the-answer weakness.
IPv4 ARP — Mechanism, per net/ipv4/arp.c
The packet
ARP is its own EtherType (0x0806), not carried inside IP. Its header (struct arphdr, include/uapi/linux/if_arp.h) is deliberately address-family-agnostic:
struct arphdr {
__be16 ar_hrd; /* hardware type: ARPHRD_ETHER = 1 */
__be16 ar_pro; /* protocol type: ETH_P_IP = 0x0800 */
unsigned char ar_hln; /* hardware addr length: 6 for Ethernet */
unsigned char ar_pln; /* protocol addr length: 4 for IPv4 */
__be16 ar_op; /* opcode: ARPOP_REQUEST=1, ARPOP_REPLY=2 */
/* followed by: sender HW, sender IP, target HW, target IP */
};For Ethernet+IPv4 the four trailing variable-length fields are sender-MAC (6), sender-IP (4), target-MAC (6), target-IP (4). In a request, the target-MAC is left empty (it is what we are asking for); in a reply it is filled with the answer. The opcode is the only field distinguishing the two directions — ARPOP_REQUEST = 1, ARPOP_REPLY = 2.
Receiving — arp_rcv → arp_process
Every inbound ARP frame lands in arp_rcv(), which sanity-checks the header and hands off to arp_process() (net/ipv4/arp.c, v6.12). arp_process is the heart of the implementation. Its logic, walked in order:
- Validate and extract the sender IP (
sip), sender MAC (sha), target IP (tip), target MAC (tha); drop anything that is neither a request nor a reply. - Duplicate-address-detection special case: if
sip == 0, this is an IPv4 DAD probe (a host checking whether its intended address is already in use, per RFC 5227); if the probedtipis one of our local addresses, reply so the prober knows the address is taken. - Should we answer? For a request whose
tiproutes to a local address (RTN_LOCAL), and unlessarp_ignore/arp_filterpolicy suppresses it, send anARPOP_REPLYwith our MAC. The reply is built and transmitted byarp_send_dst→arp_create→arp_xmit. - Proxy ARP: if the request is for an address we do not own but can route to (and forwarding plus proxy-ARP is enabled), we may answer on the real owner’s behalf — see Proxy ARP below.
- Learn from the packet: regardless of whether we replied, update our neighbour table with the sender’s
sip → shamapping. This is the crucial line — every ARP frame, request or reply, teaches us the sender’s mapping, which is why the requester is already known to the target by the time the reply comes back.
The learning step is governed by whether an entry already exists or arp_accept permits creating one, and by gratuitous-ARP detection (next). The resulting state push is via neigh_update(), which drives the generic neighbour state machine — a received reply to our request moves the entry to NUD_REACHABLE, while a broadcast or unsolicited update yields the weaker NUD_STALE:
/* from arp_process(): a unicast reply asserts reachability;
a broadcast/request only refreshes to STALE */
if (arp->ar_op != htons(ARPOP_REPLY) || skb->pkt_type != PACKET_HOST)
state = NUD_STALE;
neigh_update(n, sha, state, override ? NEIGH_UPDATE_F_OVERRIDE : 0, 0);Sending a request — arp_solicit
When the IP layer has a packet but the neighbour entry has no valid MAC, the neighbour machine calls back into ARP via arp_solicit(), which after some arp_announce-policy selection of the source address ultimately calls arp_send_dst(ARPOP_REQUEST, ...) to broadcast the query (net/ipv4/arp.c, v6.12). The number of probes, retransmit timing, and what happens when all probes go unanswered are properties of the neighbour state machine in The Neighbour Subsystem, not of ARP itself.
Gratuitous ARP — Announcing Yourself
A gratuitous ARP (GARP) is an ARP message a host sends about its own address without anyone having asked — the target IP equals the sender IP. Its purposes: announce a new MAC for an address (after a failover or a NIC swap), prime neighbours’ caches, and detect address conflicts. The kernel’s precise definition is in arp_is_garp() (net/ipv4/arp.c, v6.12):
static bool arp_is_garp(struct net *net, struct net_device *dev,
int *addr_type, __be16 ar_op,
__be32 sip, __be32 tip,
unsigned char *sha, unsigned char *tha)
{
bool is_garp = tip == sip; /* target IP == sender IP */
/* a gratuitous *reply* additionally requires target HW == sender HW */
if (is_garp && ar_op == htons(ARPOP_REPLY))
is_garp = tha && !memcmp(tha, sha, dev->addr_len);
if (is_garp) {
*addr_type = inet_addr_type_dev_table(net, dev, sip);
if (*addr_type != RTN_UNICAST)
is_garp = false;
}
return is_garp;
}So a GARP is recognised when tip == sip (and, for the reply form, the target MAC also equals the sender MAC). A gratuitous announcement can be sent as either a request (target = own IP, the classic form) or a reply (some stacks prefer this). Whether a host accepts a GARP into its cache is controlled by the arp_accept sysctl (net.ipv4.conf.*.arp_accept): by default Linux does not create a new neighbour entry from an unsolicited/gratuitous ARP — it will only update an entry that already exists — which limits cache-poisoning by silent announcements. The relevant logic in arp_process only creates a fresh entry from a GARP/unsolicited reply when arp_accept(in_dev, sip) returns true and additional address-type checks pass (net/ipv4/arp.c, v6.12). The most common real-world use of GARP is VIP failover (keepalived, VRRP, cloud floating IPs): the new active node blasts a gratuitous ARP so every host and switch on the segment updates its mapping to the new MAC within milliseconds.
Proxy ARP — Answering for Someone Else
Proxy ARP is a host (usually a router) answering ARP requests for addresses it does not own but can forward to — making two separated L2 segments appear to be one. In arp_process, after a request fails the RTN_LOCAL test but the box is a forwarder (IN_DEV_FORWARD), the code checks arp_fwd_proxy() (classic proxy ARP, requiring the route to exit a different device, gated by net.ipv4.conf.*.proxy_arp) and arp_fwd_pvlan() (RFC 3069 private-VLAN proxy, which uniquely replies back onto the same interface) (net/ipv4/arp.c, v6.12):
static inline int arp_fwd_proxy(struct in_device *in_dev,
struct net_device *dev, struct rtable *rt)
{
if (rt->dst.dev == dev) return 0; /* don't proxy onto same dev */
if (!IN_DEV_PROXY_ARP(in_dev)) return 0; /* proxy_arp sysctl off */
/* ... medium-id checks ... */
}There is also a finer-grained variant: a per-host proxy entry added with ip neigh add proxy <ip> dev <dev> (a pneigh_entry), which the code consults via pneigh_lookup(&arp_tbl, ...). This lets you proxy specific addresses rather than blanket-proxying everything routable, and is the mechanism behind point-to-point/Proxy-ARP-on-bridge setups. Proxy ARP is powerful but easy to misuse — it breaks the assumption that an IP on a segment has exactly one owner, and over-broad proxy_arp can absorb traffic for addresses you did not intend.
IPv6 Neighbour Discovery — Mechanism, per net/ipv6/ndisc.c
IPv6 has no ARP. Resolution, router discovery, redirect, and address autoconfiguration are unified into Neighbour Discovery, five ICMPv6 message types (include/net/ndisc.h):
| Type | Name | Role |
|---|---|---|
| 133 | Router Solicitation (RS) | “Are there any routers?” sent by a host on boot |
| 134 | Router Advertisement (RA) | A router announcing prefixes, MTU, and itself as a gateway |
| 135 | Neighbour Solicitation (NS) | The ND equivalent of an ARP request: “who has 2001:db8::5?” |
| 136 | Neighbour Advertisement (NA) | The ND equivalent of an ARP reply: “2001:db8::5 is at …” |
| 137 | Redirect | A router telling a host a better first hop |
The address-resolution pair is NS/NA (the direct analogues of ARP request/reply); RS/RA drive router discovery and Stateless Address Autoconfiguration (SLAAC) (RFC 4862), which is how an IPv6 host learns its prefix and default gateway without DHCP. Each message carries options — the Source/Target Link-Layer Address options (types 1 and 2) carry the MAC, the Prefix Information option (3) and MTU option (5) appear in RAs (include/net/ndisc.h).
The solicited-node multicast trick
ARP broadcasts to every host on the link, which is wasteful. ND instead sends an NS to the target’s solicited-node multicast address — a group every host automatically joins, derived from the low 24 bits of its own IPv6 address. The kernel builds it in addrconf_addr_solict_mult() (include/net/addrconf.h):
static inline void addrconf_addr_solict_mult(const struct in6_addr *addr,
struct in6_addr *solicited)
{
ipv6_addr_set(solicited,
htonl(0xFF020000), 0, htonl(0x1),
htonl(0xFF000000) | addr->s6_addr32[3]);
/* → ff02::1:ffXX:XXXX, where XXXXXX = low 24 bits of the target */
}So resolving 2001:db8::dead:beef sends the NS to ff02::1:ffad:beef. Only hosts whose addresses share those low 24 bits receive it — typically just the target — so ND multicast disturbs far fewer NICs than ARP broadcast. The corresponding source-side function ndisc_solicit() computes this multicast destination (addrconf_addr_solict_mult(target, &mcaddr)) when probing a not-yet-known neighbour (net/ipv6/ndisc.c, v6.12).
Receiving — ndisc_rcv, and the hop-limit-255 guard
All ND messages enter ndisc_rcv(), which dispatches by ICMPv6 type (net/ipv6/ndisc.c, v6.12):
enum skb_drop_reason ndisc_rcv(struct sk_buff *skb)
{
struct nd_msg *msg = (struct nd_msg *)skb_transport_header(skb);
/* ... */
if (ipv6_hdr(skb)->hop_limit != 255) /* RFC 4861 §3.1, §11.2 */
return SKB_DROP_REASON_IPV6_NDISC_HOP_LIMIT;
if (msg->icmph.icmp6_code != 0)
return SKB_DROP_REASON_IPV6_NDISC_BAD_CODE;
switch (msg->icmph.icmp6_type) {
case NDISC_NEIGHBOUR_SOLICITATION: reason = ndisc_recv_ns(skb); break;
case NDISC_NEIGHBOUR_ADVERTISEMENT: reason = ndisc_recv_na(skb); break;
case NDISC_ROUTER_SOLICITATION: reason = ndisc_recv_rs(skb); break;
case NDISC_ROUTER_ADVERTISEMENT: reason = ndisc_router_discovery(skb); break;
case NDISC_REDIRECT: reason = ndisc_redirect_rcv(skb); break;
}
return reason;
}The hop_limit != 255 check is a small but important security mechanism: RFC 4861 requires every ND message to be sent with an IPv6 Hop Limit of 255 and that receivers drop any ND message arriving with a lower value. Because routers decrement the hop limit, a packet that still has 255 on arrival cannot have crossed a router — this is the Generalized TTL Security Mechanism (GTSM), and it guarantees ND messages are on-link only, preventing off-link attackers from injecting bogus solicitations or advertisements.
NS/NA flags drive the neighbour state
When an NA arrives, ndisc_recv_na() translates its three ICMPv6 flags into neighbour-table updates (net/ipv6/ndisc.c, v6.12):
new_state = msg->icmph.icmp6_solicited ? NUD_REACHABLE : NUD_STALE;
/* ... */
ndisc_update(dev, neigh, lladdr, new_state,
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),
NDISC_NEIGHBOUR_ADVERTISEMENT, &ndopts);The Solicited (S) flag means “this NA answers a request you sent” → trust it to NUD_REACHABLE; an unsolicited NA (the IPv6 cousin of gratuitous ARP, used for failover announcements) only refreshes to NUD_STALE. The Override (O) flag tells the receiver whether it may replace an existing cached MAC. The Router (R) flag marks the sender as a router so the host knows it can be a default gateway. A comment in the source notes that RFC 9131 (2021) updated RFC 4861 to let a forwarding node create a STALE entry from an unsolicited NA carrying a target-LL-address option even with no prior cache entry — a deliberate, recent refinement to reduce first-packet latency on routers (net/ipv6/ndisc.c, v6.12). IPv6 also has Duplicate Address Detection (DAD): before using a new address a host sends an NS for its own tentative address from the unspecified source ::; any NA in response means the address is taken (handled around the tentative/optimistic flag checks in ndisc_send_ns).
Proxy NDP mirrors proxy ARP: a forwarder can answer NS for addresses it does not own, gated by net.ipv6.conf.*.proxy_ndp and per-target ip -6 neigh add proxy entries (pneigh_lookup(&nd_tbl, ...)), as seen in the ndisc_recv_na self-proxy guard (net/ipv6/ndisc.c, v6.12).
ARP / ND Spoofing — The Shared Weakness
Both protocols are unauthenticated: a reply is believed because it arrived, not because the sender proved ownership. An attacker on the same link can therefore send forged ARP replies (or unsolicited NAs) claiming “the gateway’s IP is at my MAC,” poisoning every host’s neighbour cache and routing the segment’s traffic through the attacker — the classic man-in-the-middle attack. The kernel’s defences are partial: arp_accept=0 (the default) refuses to create new entries from unsolicited ARP, limiting drive-by poisoning, but does nothing against an attacker who races a legitimate reply or floods overrides; the LOCKTIME window (net.ipv4.neigh.*.locktime) prevents an entry from being overwritten too soon after a previous update, taking the first of several back-to-back replies (visible in arp_process’s override computation). For IPv6, the hop-limit-255 GTSM guard confines attacks to on-link adversaries but not against on-link ones. Real mitigations live above the protocol:
- Static/permanent neighbour entries (
ip neigh add … nud permanent) for critical addresses (the default gateway) — they cannot be overwritten by spoofed replies. - Switch-level protection: Dynamic ARP Inspection (DAI) and DHCP Snooping on managed switches validate ARP against a trusted binding table; IPv6 has RA Guard and ND Inspection (SAVI, RFC 7039).
- SEND (SEcure Neighbour Discovery, RFC 3971) cryptographically signs ND with CGAs — rarely deployed in practice.
- Monitoring with
arpwatch/ndpmon, which alert on IP↔MAC changes.
The sysctl defaults and semantics above are confirmed against the authoritative kernel documentation at v6.12 (Documentation/networking/ip-sysctl.rst, v6.12): arp_accept defaults to 0 (“don’t create new entries in the ARP table” from gratuitous ARP — though an entry already present is always updated); arp_ignore and arp_announce default to 0; arp_filter and proxy_arp/proxy_ndp are booleans, off by default.
Uncertain
Verify: the exact default value and semantics of
neigh.*.locktime(theLOCKTIMEwindow referenced inarp_process’soverridecomputation). Reason:locktimeis under theneigh/sysctl tree and is not documented inDocumentation/networking/ip-sysctl.rst, which I did fetch and verify for all thearp_*/proxy_*values; I read its use site inarp.cbut not an authoritative default. To resolve: confirmlocktime’s default against the neighbour-subsystem documentation ornet/core/neighbour.cat v6.12. uncertain
Diagnostics — Inspecting and Manipulating the Caches
The neighbour table is inspected and edited with ip neigh (the legacy arp tool reads the same table):
$ ip neigh show
192.0.2.1 dev eth0 lladdr aa:bb:cc:dd:ee:ff REACHABLE
192.0.2.9 dev eth0 lladdr 11:22:33:44:55:66 STALE
fe80::1 dev eth0 lladdr de:ad:be:ef:00:01 router REACHABLE
2001:db8::5 dev eth0 FAILED # resolution gave up — neighbour unreachable
$ ip neigh add 192.0.2.1 lladdr aa:bb:cc:dd:ee:ff dev eth0 nud permanent # static, spoof-proof
$ ip neigh add proxy 198.51.100.7 dev eth0 # per-host proxy ARP
$ ip -6 neigh add proxy 2001:db8::7 dev eth0 # per-host proxy NDP
$ ip -s neigh show 192.0.2.1 # with statsThe state words (REACHABLE, STALE, FAILED, etc.) are the generic neighbour states — their precise meaning and transitions are The Neighbour Subsystem. To send a gratuitous announcement manually, arping -A -I eth0 192.0.2.50 (gratuitous reply) or -U (gratuitous request) is the usual tool; ndisc6/rdisc6 probe IPv6 NS/RA.
Alternatives and Boundaries
ARP and ND are not interchangeable with anything — they are the only standard on-link resolution for IPv4 and IPv6 respectively — but several adjacent mechanisms displace or bypass them:
- Static configuration (permanent neighbour entries) replaces dynamic resolution where the mapping is known and stability/security matter (gateways, anti-spoofing).
- Overlay/tunnel encapsulation (VXLAN, Geneve) does its own L2-over-L3 endpoint resolution, often via a control plane (EVPN/BGP) or multicast, instead of flooding ARP across the underlay — relevant to Tunnel and Overlay Interfaces and container networking.
- In containers, a
vethpair plus bridge means ARP/ND happen inside the namespace’s own neighbour table; CNI plugins sometimes pre-populate permanent entries or rely on proxy ARP/NDP on the host side. The kernel mechanism is identical; only the topology differs (see veth Pairs, Linux Bridges and Software Switching).
The decisive boundary: ARP/ND resolve only on-link (same broadcast domain) next hops. For an off-link destination, routing has already chosen a gateway IP, and ARP/ND resolve that gateway’s MAC — never the ultimate destination’s. This is why a host with a wrong default gateway can ARP fine but reach nothing: the resolution succeeds, the path is wrong.
Production Notes
The single most operationally visible use of these protocols is VIP failover. keepalived/VRRP, Pacemaker, and cloud “floating IP” implementations all rely on a gratuitous ARP (or unsolicited NA) burst at takeover so that the segment’s hosts and switches relearn the VIP→new-MAC mapping within milliseconds; a failover that doesn’t GARP leaves clients sending to a dead MAC until their cache entries age out (potentially tens of seconds), which presents as a “split-second” outage that is actually the neighbour-cache timeout. A second recurring incident class is neighbour-table overflow on large flat L2 segments or busy routers: the gc_thresh1/2/3 garbage-collection thresholds (net.ipv4.neigh.default.gc_thresh*) cap the table, and exceeding gc_thresh3 triggers the kernel log message “neighbour: arp_cache: neighbor table overflow!” and dropped packets — the fix is raising the thresholds, and the mechanism (GC, thresholds) belongs to The Neighbour Subsystem. Third, proxy ARP misconfiguration is a classic foot-gun: a router with blanket proxy_arp=1 will answer for addresses it can route to but should not, silently black-holing or hijacking traffic; prefer per-host proxy entries over the global sysctl.
See Also
- The Neighbour Subsystem — the generic neighbour table and its NUD state machine (INCOMPLETE/REACHABLE/STALE/DELAY/PROBE/FAILED), GC thresholds, and the
neigh_updatethat both ARP and ND feed; this note feeds it, that note runs it - IP Routing Decision and Forwarding — produces the next-hop IP that this note resolves to a MAC
- The IP Layer — the layer that requests resolution before a frame can be transmitted
- Tunnel and Overlay Interfaces — overlay networks that replace on-link ARP/ND flooding with a control plane
- veth Pairs · Linux Bridges and Software Switching — where ARP/ND happen inside container network namespaces
- MOC: Linux Networking Stack MOC (§6 — Routing, the FIB, and the Neighbour Subsystem)