8.6 Receiving IPv6 Packets
In the previous section, we discussed address configuration—how to give a machine an identity on the network. Now that it has an identity, the real action begins.
When a packet with an IPv6 header arrives over the wire, the first thing the kernel does is not hand it to an application, but run it through a "security check." This process happens in ipv6_rcv(), which is mandatory for all IPv6 packets, whether unicast or multicast (remember, IPv6 has no broadcast).
The receive path here is very similar to IPv4, but IPv6 has its own quirks.
ipv6_rcv(): The First Line of Defense
The job of ipv6_rcv() is simple: keep out any packets that are clearly malformed or break the rules.
Let's look at the source code. This function receives a sk_buff (which we usually call an skb), along with the associated network device information.
int ipv6_rcv(struct sk_buff *skb, struct net_device *dev, struct packet_type *pt,
struct net_device *orig_dev)
{
const struct ipv6hdr *hdr;
u32 pkt_len;
struct inet6_dev *idev;
struct net *net = dev_net(skb->dev);
...
hdr = ipv6_hdr(skb);
First, it grabs the network namespace and the IPv6 header. Next come several well-known "sanity checks." These checks may seem tedious, but behind every one lies either a hard RFC requirement or a hard-learned lesson.
First, the version field must be correct.
Although Ethernet drivers typically dispatch packets to the IPv6 module based on the EtherType, the kernel habitually double-checks that the version field in the header is 6. If not, the packet is dropped immediately.
if (hdr->version != 6)
goto err;
Second, loopback address detection.
There is a counterintuitive rule: if an incoming packet from the outside has a destination address of the loopback address (::1), it is illegal. Loopback addresses should only circulate internally and should never appear on a network interface.
if (!(dev->flags & IFF_LOOPBACK) &&
ipv6_addr_loopback(&hdr->daddr))
goto err;
Third, the source address must not be a multicast address.
According to RFC 4291, multicast addresses can only be used as destination addresses, never as source addresses. If you receive a packet with a source address starting with ff00::, drop it without hesitation.
if (ipv6_addr_is_multicast(&hdr->saddr))
goto err;
Handling the Hop-by-Hop Extension Header
If ipv6_rcv() decides the packet looks legitimate, the next step is to handle a special character: the Hop-by-Hop Options extension header.
This is a very unique feature in IPv6. It is the only extension header that must be processed by every router along the path. In the IPv6 header, the nexthdr field points to the type of the next header. If this value is 0 (NEXTHDR_HOP), it means a Hop-by-Hop header immediately follows, and the kernel must stop to parse it right away.
if (hdr->nexthdr == NEXTHDR_HOP) {
if (ipv6_parse_hopopts(skb) < 0) {
IP6_INC_STATS_BH(net, idev, IPSTATS_MIB_INHDRERRORS);
rcu_read_unlock();
return NET_RX_DROP;
}
}
If parsing fails (e.g., due to a malformed format), the statistical counter is incremented and the packet is dropped.
Passing Through the Netfilter Hook
If everything goes smoothly, the last stop in ipv6_rcv() is Netfilter.
return NF_HOOK(NFPROTO_IPV6, NF_INET_PRE_ROUTING, skb, dev, NULL,
ip6_rcv_finish);
The NF_HOOK macro is a classic design in the kernel network stack. It hangs the packet on the NF_INET_PRE_ROUTING hook. If you have configured rules for iptables or nftables (such as the PREROUTING chain in the raw table), they will be triggered here.
If the firewall decides to let the packet through, the flow proceeds to ip6_rcv_finish().
ip6_rcv_finish(): The Fork in the Road
The name ip6_rcv_finish() sounds like wrap-up work, but it is actually where the packet's destiny is decided.
int ip6_rcv_finish(struct sk_buff *skb)
{
...
if (!skb_dst(skb))
ip6_route_input(skb);
return dst_input(skb);
}
The logic here happens in two steps:
- Route lookup: If the skb does not yet have a destination cache (dst entry) bound to it, it calls
ip6_route_input()to look up the routing table. Under the hood, this callsfib6_rule_lookup()(orfib6_lookup(), depending on whether you have enabled multiple routing table support). - Delivery: After obtaining the routing result, it calls
dst_input(skb).
dst_input is a nifty little function that directly invokes the input callback function preset in the routing lookup result.
It's like handing a package to a courier. After scanning the tracking number, the courier finds:
- If it's local, it goes into the local delivery bin (
ip6_input). - If it's for someone else, it goes onto the forwarding conveyor belt (
ip6_forward). - If it's for a group of people (multicast), it goes to multicast handling (
ip6_mc_input). - If the route cannot be found or the packet is explicitly meant to be dropped, it goes into the incinerator (
ip6_pkt_discard), and an ICMPv6 "Destination Unreachable" message is sent back as a courtesy.
Next, let's look at the two separate paths: local delivery and forwarding.
Local Delivery
When the routing system determines that this packet is destined for the local machine, dst_input calls ip6_input().
int ip6_input(struct sk_buff *skb)
{
return NF_HOOK(NFPROTO_IPV6, NF_INET_LOCAL_IN, skb, skb->dev, NULL,
ip6_input_finish);
}
Here, the packet goes through another Netfilter hook (NF_INET_LOCAL_IN), which is the INPUT chain of iptables. Only after passing this checkpoint does it enter the actual processing logic, ip6_input_finish().
Parsing Extension Headers and Dispatching to Protocols
The core task of ip6_input_finish() is peeling the onion.
A string of extension headers may be attached after the IPv6 base header. With each layer peeled away, the nexthdr field tells us what is inside. Only when we reach the core do we find TCP, UDP, or ICMPv6.
static int ip6_input_finish(struct sk_buff *skb)
{
struct net *net = dev_net(skb_dst(skb)->dev);
const struct inet6_protocol *ipprot;
struct inet6_dev *idev;
unsigned int nhoff;
int nexthdr;
bool raw;
rcu_read_lock();
resubmit:
idev = ip6_dst_idev(skb_dst(skb));
if (!pskb_pull(skb, skb_transport_offset(skb)))
goto discard;
nhoff = IP6CB(skb)->nhoff;
nexthdr = skb_network_header(skb)[nhoff];
raw = raw6_local_deliver(skb, nexthdr);
The code first tries to hand the packet to a Raw Socket. If you are using a packet capture tool or a custom Raw Socket, this is where you get the raw data first.
Next, the kernel takes the nexthdr and looks it up in a global array, inet6_protos. This array acts like a dispatch center, with handler functions for various protocols registered inside it.
if ((ipprot = rcu_dereference(inet6_protos[nexthdr])) != NULL) {
int ret;
if (ipprot->flags & INET6_PROTO_FINAL) {
const struct ipv6hdr *hdr;
nf_reset(skb);
skb_postpull_rcsum(skb, skb_network_header(skb),
skb_network_header_len(skb));
hdr = ipv6_hdr(hdr);
A Special Case for MLD (Gotcha)
There is a very subtle gotcha here regarding multicast source filtering.
Normally, if source filtering is enabled on a network interface and a certain multicast address does not allow data from a certain source address, the kernel should drop the packet. However, MLD (Multicast Listener Discovery) protocol packets are an exception.
RFC 3810 explicitly states that MLD messages are not subject to source filtering and must be processed. Why? Because MLD is the protocol used to maintain multicast group membership. If you filter it out, the entire multicast mechanism collapses.
Therefore, a specific check is intentionally added in the code:
if (ipv6_addr_is_multicast(&hdr->daddr) &&
!ipv6_chk_mcast_addr(skb->dev, &hdr->daddr,
&hdr->saddr) &&
!ipv6_is_mld(skb, nexthdr, skb_network_header_len(skb)))
goto discard;
The logic is: if it is a multicast packet, and the source address failed the check, and it is not an MLD packet, only then is it dropped. If it is an MLD packet, even if the source address is wrong, it must be let through.
IPsec Policy Check and Protocol Dispatch
Continuing down. If the protocol was not registered with the INET6_PROTO_NOPOLICY flag, it means an IPsec policy check (xfrm6_policy_check) is required. This is for security.
if (!(ipprot->flags & INET6_PROTO_NOPOLICY) &&
!xfrm6_policy_check(NULL, XFRM_POLICY_IN, skb))
goto discard;
ret = ipprot->handler(skb);
if (ret > 0)
goto resubmit;
else if (ret == 0)
IP6_INC_STATS_BH(net, idev, IPSTATS_MIB_INDELIVERS);
The line ipprot->handler(skb) means the packet finally leaves the network layer and is handed off to the transport layer (such as tcp_v6_rcv or udpv6_rcv).
If handler returns a value greater than 0, it means this is actually another extension header, and resubmit needs to loop again to continue parsing. If it returns 0, it means successful delivery, and the statistical counter is incremented.
Forwarding
If the routing table lookup reveals that this packet is not for us but needs to be forwarded as an intermediary, dst_input calls ip6_forward().
Forwarding and local delivery are completely different things. Acting as a router, the kernel must follow a set of rules to ensure the packet can be sent out without being used as an attack springboard.
int ip6_forward(struct sk_buff *skb)
{
struct dst_entry *dst = skb_dst(skb);
struct ipv6hdr *hdr = ipv6_hdr(skb);
struct inet6_skb_parm *opt = IP6CB(skb);
struct net *net = dev_net(dst->dev);
u32 mtu;
if (net->ipv6.devconf_all->forwarding == 0)
goto error;
First, the system must have forwarding enabled (/proc/sys/net/ipv6/conf/all/forwarding). If it is a regular host (forwarding is 0), packets destined for others should be dropped.
LRO and Security Checks
if (skb_warn_if_lro(skb))
goto drop;
if (!xfrm6_policy_check(NULL, XFRM_POLICY_FWD, skb)) {
...
goto drop;
}
if (skb->pkt_type != PACKET_HOST)
goto drop;
There are a few key checks here:
- LRO check: If the network interface has LRO (Large Receive Offload) enabled, the oversized merged packets might cause issues during forwarding. The kernel typically does not support forwarding these huge packets "cobbled together" by hardware, so they are dropped immediately.
- Packet type check:
skb->pkt_typemust bePACKET_HOST(addressed to us). If it isPACKET_BROADCASTor another type, it indicates something is wrong at the link layer, and the packet cannot be forwarded.
Router Alert
IPv6 has an interesting mechanism called Router Alert. If a packet carries a special extension header telling upper-layer applications "look at this packet," the forwarding logic becomes slightly more complex.
if (opt->ra) {
u8 *ptr = skb_network_header(skb) + opt->ra;
if (ip6_call_ra_chain(skb, (ptr[2]<<8) + ptr[3]))
return 0;
}
The code attempts to hand the packet to a Raw Socket that has registered the IPV6_ROUTER_ALERT option. If a socket successfully receives this packet (ip6_call_ra_chain returns non-zero), the forwarding process stops here—the packet has been "intercepted" by user space and will not be forwarded any further.
Hop Limit
This is one of the most common reasons for forwarding failure. IPv6 uses hop_limit instead of IPv4's TTL.
if (hdr->hop_limit <= 1) {
skb->dev = dst->dev;
icmpv6_send(skb, ICMPV6_TIME_EXCEED, ICMPV6_EXC_HOPLIMIT, 0);
IP6_INC_STATS_BH(net,
ip6_dst_idev(dst), IPSTATS_MIB_INHDRERRORS);
kfree_skb(skb);
return -ETIMEDOUT;
}
If hop_limit has already been decremented to 1 (meaning the next hop would be 0), the packet can no longer be forwarded. The kernel sends an ICMPv6 "Time Exceeded" message back to the source address and drops the packet. This is the same principle behind the traceroute tool.
PMTU and ICMPv6 Packet Too Big
This is an extremely important point in IPv6 forwarding. Intermediate IPv6 routers are not allowed to fragment packets. This means that if a huge packet wants to pass through a network with a small MTU, the intermediate router must tell the source host: "Buddy, your packet is too big, it won't fit through here."
mtu = dst_mtu(dst);
if (mtu < IPV6_MIN_MTU)
mtu = IPV6_MIN_MTU;
if ((!skb->local_df && skb->len > mtu && !skb_is_gso(skb)) ||
(IP6CB(skb)->frag_max_size && IP6CB(skb)->frag_max_size > mtu)) {
skb->dev = dst->dev;
icmpv6_send(skb, ICMPV6_PKT_TOOBIG, 0, mtu);
IP6_INC_STATS_BH(net,
ip6_dst_idev(dst), IPSTATS_MIB_INTOOBIGERRORS);
...
return -EMSGSIZE;
}
If the packet length (skb->len) is found to be greater than the egress MTU, the kernel triggers icmpv6_send() to send an ICMPv6 Type 2 (Packet Too Big) message, including the correct MTU value in the message.
This is the core of PMTU (Path MTU Discovery): after receiving this message, the source host reduces the MTU for the path and retransmits the packet. If this logic breaks, you'll find that small websites load fine, but large images or large files fail to load completely—a classic PMTU black hole.
The Final Sprint
If all the above checks pass, the final step is to decrement the hop count and send the packet out.
if (skb_cow(skb, dst->dev->hard_header_len)) {
...
goto drop;
}
hdr = ipv6_hdr(skb);
hdr->hop_limit--;
IP6_INC_STATS_BH(net, ip6_dst_idev(dst), IPSTATS_MIB_OUTFORWDATAGRAMS);
...
return NF_HOOK(NFPROTO_IPV6, NF_INET_FORWARD, skb, skb->dev, dst->dev,
ip6_forward_finish);
skb_cow() ensures the skb's header data is writable (because if it has been cloned, directly modifying hop_limit would affect other places holding a reference to this skb).
After decrementing hop_limit by 1, the packet passes through the Netfilter NF_INET_FORWARD hook (the FORWARD chain) one more time, and finally calls ip6_forward_finish(), which essentially calls dst_output(skb) to hand the packet off to the driver for transmission.
Summary
In this section, we traced the two possible fates of an IPv6 packet after it enters the kernel:
- Local delivery:
ipv6_rcv->ip6_rcv_finish->ip6_input->ip6_input_finish. During this process, the kernel peels the onion of extension headers and finally hands the packet to TCP/UDP or a Raw Socket. - Forwarding:
ipv6_rcv->ip6_rcv_finish->ip6_forward. During this process, the kernel acts as a router, checking the hop limit, PMTU, and performing source address verification, before sending the packet to the next hop.
This flow looks very similar to IPv4, but in the details—especially the handling of extension headers, the mandatory PMTU requirement, and the special-case handling of MLD—it reflects IPv6's design philosophy of "simplifying the header and pushing complexity to extensions."