scapy icon indicating copy to clipboard operation
scapy copied to clipboard

[ndp] neighsol(): optional strict validation of IPv6 Hop Limit (==255) per RFC 4861

Open itchenfei opened this issue 3 months ago • 16 comments

Summary

This PR adds an optional strict mode to scapy.layers.inet6.neighsol() to validate the IPv6 Hop Limit (HLIM) == 255 on received Neighbor Advertisement (NA) replies. When enabled, the function first narrows capture with a BPF filter and then performs a second, protocol-level check in Python before returning the packet.

Motivation / Rationale

Per RFC 4861 (Neighbor Discovery for IPv6), ND control messages MUST be sent with Hop Limit = 255, and receivers MUST verify HLIM=255 to prevent off-link spoofing. Today neighsol() sets hlim=255 on the sent NS but does not validate HLIM on received NA packets. This can accept responses that traversed a router (HLIM<255) or were injected off-link. Enforcing (optionally) HLIM==255 improves standards compliance and hardens neighbor discovery. (See Scapy’s contribution guidelines for tests and coding style; tox instructions used for local testing. )

itchenfei avatar Aug 27 '25 03:08 itchenfei

Codecov Report

:white_check_mark: All modified and coverable lines are covered by tests. :white_check_mark: Project coverage is 80.90%. Comparing base (29433fc) to head (ce406ff). :warning: Report is 1 commits behind head on master.

Additional details and impacted files
@@            Coverage Diff             @@
##           master    #4829      +/-   ##
==========================================
- Coverage   80.90%   80.90%   -0.01%     
==========================================
  Files         366      366              
  Lines       90132    90132              
==========================================
- Hits        72921    72918       -3     
- Misses      17211    17214       +3     
Files with missing lines Coverage Δ
scapy/layers/inet6.py 88.57% <ø> (ø)

... and 4 files with indirect coverage changes

:rocket: New features to boost your workflow:
  • :snowflake: Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

codecov[bot] avatar Aug 27 '25 09:08 codecov[bot]

Do you have a use case in mind that requires this check in BPF?

The hop limit check could be done in pure Python.

guedou avatar Aug 27 '25 09:08 guedou

Do you have a use case in mind that requires this check in BPF?

The hop limit check could be done in pure Python.

Thanks a lot for the suggestion, @guedou! I refactored the change to remove the BPF dependency and do the hop-limit validation in pure Python. Concretely: I switched neighsol() to srp and added a stop_filter so we only treat responses as valid when ICMPv6ND_NA is present and IPv6.hlim == 255. Please take another look.

itchenfei avatar Aug 27 '25 15:08 itchenfei

Is there a need for that change ?

guedou avatar Aug 27 '25 15:08 guedou

Is there a need for that change ?

image This is not a real world ipv6 address, so i directly paste this image.

This is Qualcomm Device Dial NIC and response 2 NA messages. If don't switch from srp1 to srp, will get wrong packet.

Do you have any other better suggestions?

itchenfei avatar Aug 27 '25 15:08 itchenfei

Is there a need for that change ?

image This is not a real world ipv6 address, so i directly paste this image. This is Qualcomm Device Dial NIC and response 2 NA messages. If don't switch from srp1 to srp, will get wrong packet.

Do you have any other better suggestions?

In this scenario, using srp1 with a stop_filter still returns the wrong Ethernet frame. Using srp without a stop_filter waits until the timeout occurs. The reliable approach is to use srp with a stop_filter and then iterate over the answers to select the first compliant packet (e.g., ICMPv6ND_NA with IPv6.hlim == 255).

itchenfei avatar Aug 27 '25 16:08 itchenfei

I personally disagree with @guedou, the BPF idea was smart, and I feel more elegant

gpotter2 avatar Aug 28 '25 06:08 gpotter2

For readability, lfilter that only check the hlim value seems like a good choice.

I am unsure that we need any performance improvement there.

@itchenfei I think that what you really need is to filter hlim == 255. Scapy should take care of the rest.

guedou avatar Aug 28 '25 06:08 guedou

@itchenfei can you share the complete neighsol() call that you used?

guedou avatar Aug 28 '25 15:08 guedou

@itchenfei can you share the complete neighsol() call that you used?

I just sent an ICMPv6 packet, and the NDP was triggered automatically.

import os

from scapy.arch import get_if_addr6
from scapy.layers.inet6 import IPv6, ICMPv6EchoRequest
from scapy.sendrecv import sr1
from scapy.config import conf

PING_DEST = "2001:da8:d800:642::248"
PING_INTERFACE = "eth0"
packet_id = os.getpid() & 0xFFFF

ip_layer = IPv6(
    dst=PING_DEST,
    src=get_if_addr6(PING_INTERFACE)
)
protocol_layer = ICMPv6EchoRequest(
    data="A" * 50,
    id=packet_id,
    seq=1,
)
packet = ip_layer / protocol_layer

reply = sr1(packet, timeout=1, verbose=0)
if reply:
    reply.show()

print(f"ipv6 neighbor: {conf.netcache.in6_neighbor}")

itchenfei avatar Aug 29 '25 01:08 itchenfei

I personally disagree with @guedou, the BPF idea was smart, and I feel more elegant

For readability, lfilter that only check the hlim value seems like a good choice.

I am unsure that we need any performance improvement there.

@itchenfei I think that what you really need is to filter hlim == 255. Scapy should take care of the rest.

I’m not sure whether BPF will have any compatibility issues on the NT kernel, but using sniff right now seems easier to understand and maintain. c5885981c110df6fb88558c091a25123e3cc4a2a

itchenfei avatar Aug 29 '25 01:08 itchenfei

@itchenfei your code is not calling neighsol()at all.

The following works fine for me: neighsol("fe80::ba26:6cff:fe5f:4eee", "fe80:1d::1c61:245:1e6e:8092", "en7")

guedou avatar Aug 29 '25 11:08 guedou

@itchenfei your code is not calling neighsol()at all.

The following works fine for me: neighsol("fe80::ba26:6cff:fe5f:4eee", "fe80:1d::1c61:245:1e6e:8092", "en7")

When an ICMPv6 packet is sent, NDP is automatically triggered and the neighbor’s MAC address is appended as part of the ICMPv6 packet.

itchenfei avatar Aug 30 '25 06:08 itchenfei

@itchenfei thanks for your answer. Can you share a pcap files containing the NS / NA that were not matched?

Your initial fix looks like the more efficient (i.e. using a BPF filter), but I suspect a deeper bug in Scapy. Sorry for the back and forth, I should have made it clear that I am trying to understand for the root cause.

guedou avatar Aug 30 '25 07:08 guedou

@itchenfei thanks for your answer. Can you share a pcap files containing the NS / NA that were not matched?

Your initial fix looks like the more efficient (i.e. using a BPF filter), but I suspect a deeper bug in Scapy. Sorry for the back and forth, I should have made it clear that I am trying to understand for the root cause.

Thanks for digging into this! I’ve zipped the capture since GitHub only accepts archives:

icmp6.zip

pcap: icmp6.zip (attached above) It contains an NS followed by two NAs where the first has IPv6.hlim = 254 and the second IPv6.hlim = 255.

Finally, big thanks to all maintainers for the helpful feedback and for keeping Scapy efficient and reliable—digging into this taught me a lot about how filter and send/receive paths work, which will definitely help my future usage.

itchenfei avatar Aug 30 '25 12:08 itchenfei

Thanks for sharing. These NA packets do look strange. Is the following patch fixing your issue?

% git diff                 
diff --git a/scapy/layers/inet6.py b/scapy/layers/inet6.py
index b585c92e..2745291d 100644
--- a/scapy/layers/inet6.py
+++ b/scapy/layers/inet6.py
@@ -2258,6 +2258,10 @@ class ICMPv6ND_NA(_ICMPv6NDGuessPayload, _ICMPv6, Packet):
         return bytes_encode(self.tgt) + self.payload.hashret()
 
     def answers(self, other):
+        if not isinstance(self.underlayer, IPv6):
+            return False
+        if isinstance(self.underlayer.hlim != 255:
+            return False
         return isinstance(other, ICMPv6ND_NS) and self.tgt == other.tgt
 
 # associated possible options : target link-layer option, Redirected header

guedou avatar Sep 02 '25 18:09 guedou