Skip to content

send() and sendp() not working on PPPoE interface #4964

Description

@cinit

Brief description

send() and sendp() sets invalid protocol when using AF_PACKET on PPPoE interfaces, causing packets to be dropped by PPP driver (skb->protocol = 0).

Scapy version

2.7.0.dev46

Python version

Python 3.13.5, using IPython 8.35.0

Operating system

Linux 6.12.74+deb13+1-amd64

Additional environment information

When using send(with ScopedIP) and sendp() on PPPoE (ppp0) interface, Scapy sends packets via AF_PACKET without correctly setting the L3 protocol (sll_protocol). As a result, packet_snd assigns skb->protocol = 0 and causes packets to be dropped by PPP driver that rely on skb->protocol (in this case, ppp_start_xmit).

How to reproduce

Create a PPPoE link interface. Make sure it's type 512.

cat /sys/class/net/ppp0/type 
512

Send an IPv6 ICMPv6 echo request by send() and sendp().

>>> pkt = IPv6(dst="ff02::1%ppp0")/ICMPv6EchoRequest()
>>> pkt.show()
###[ IPv6 ]###
  version   = 6
  tc        = 0
  fl        = 0
  plen      = None
  nh        = ICMPv6
  hlim      = 64
  src       = fe80::be24:11c6:ebd1:9865
  dst       = ScopedIP('ff02::1', scope='ppp0')
###[ ICMPv6 Echo Request ]###
     type      = Echo Request
     code      = 0
     cksum     = None
     id        = 0x0
     seq       = 0x0
     data      = b''

>>> sendp(pkt, iface="ppp0")
.
Sent 1 packets.
>>> send(pkt)
.
Sent 1 packets.
>>> 

I also tried pkt = CookedLinux(proto=0x86DD) / IPv6(src="fe80::1", dst="ff02::2") / ICMPv6ND_RS() and pkt = PPP() / IPv6(src="fe80::1", dst="ff02::2") / ICMPv6ND_RS(), none of them worked.

Actual result

Look at tcpdump output, just nothing there.

~# tcpdump -n -i ppp0 'icmp6'
tcpdump: verbose output suppressed, use -v[v]... for full protocol decode
listening on ppp0, link-type LINUX_SLL (Linux cooked v1), snapshot length 262144 bytes

Look at ifconfig ppp0, there 2 more dropped TX packets.
Before:

~# ifconfig ppp0
ppp0: flags=4305<UP,POINTOPOINT,RUNNING,NOARP,MULTICAST>  mtu 1492
        inet 100.64.10.201  netmask 255.255.255.255  destination 100.64.0.1
        inet6 fe80::be24:11c6:ebd1:9865  prefixlen 128  scopeid 0x20<link>
        ppp  txqueuelen 3  (Point-to-Point Protocol)
        RX packets 4752172  bytes 3692861302 (3.4 GiB)
        RX errors 0  dropped 0  overruns 0  frame 0
        TX packets 4389765  bytes 935034757 (891.7 MiB)
        TX errors 0  dropped 388 overruns 0  carrier 0  collisions 0

After:

~# ifconfig ppp0
ppp0: flags=4305<UP,POINTOPOINT,RUNNING,NOARP,MULTICAST>  mtu 1492
        inet 100.64.10.201  netmask 255.255.255.255  destination 100.64.0.1
        inet6 fe80::be24:11c6:ebd1:9865  prefixlen 128  scopeid 0x20<link>
        ppp  txqueuelen 3  (Point-to-Point Protocol)
        RX packets 4752398  bytes 3692927445 (3.4 GiB)
        RX errors 0  dropped 0  overruns 0  frame 0
        TX packets 4390064  bytes 935094677 (891.7 MiB)
        TX errors 0  dropped 390 overruns 0  carrier 0  collisions 0

Run dropwatch -l kas start + scapy while True:sendp(pkt, iface="ppp0") and it shows the following:

...
1 drops at tcp_validate_incoming+1a3 (0xffffffffa21125e3) [software]
1 drops at __netif_receive_skb_core.constprop.0+144 (0xffffffffa2034584) [software]
1 drops at __netif_receive_skb_core.constprop.0+144 (0xffffffffa2034584) [software]
1 drops at __init_scratch_end+1c80a73c (0xffffffffc100a73c) [software]
12 drops at __init_scratch_end+1c80c9b0 (0xffffffffc100c9b0) [software]
10 drops at __init_scratch_end+1c80c9b0 (0xffffffffc100c9b0) [software]
...

And trace it:

bpftrace -e 'tracepoint:skb:kfree_skb /args->location == 0xffffffffc100c9b0/ { print(kstack); }'
Attaching 1 probe...

        sk_skb_reason_drop+148
        ppp_start_xmit+112
        dev_hard_start_xmit+100
        sch_direct_xmit+159
        __dev_queue_xmit+2540
        packet_sendmsg+4285
        __sys_sendto+479
        __x64_sys_sendto+36
        do_syscall_64+130
        entry_SYSCALL_64_after_hwframe+118


        sk_skb_reason_drop+148
        ppp_start_xmit+112
        dev_hard_start_xmit+100
        sch_direct_xmit+159
        __dev_queue_xmit+2540
        packet_sendmsg+4285
        __sys_sendto+479
        __x64_sys_sendto+36
        do_syscall_64+130
        entry_SYSCALL_64_after_hwframe+118

It seems that ppp_start_xmit+112 is dropping the TX packet.

Trace skb->protocol:

# bpftrace -e '
tracepoint:skb:kfree_skb
{
    printf("protocol=0x%x\n", args->protocol);
}'

protocol=0x800
protocol=0x800
protocol=0x800
protocol=0x800
protocol=0x800
protocol=0x800
protocol=0x0
protocol=0x0
protocol=0x800
protocol=0x0
protocol=0x0
protocol=0x800
protocol=0x0

There are lots of protocol=0x0 when I run scapy while True:sendp(pkt, iface="ppp0")

Packets sent via sendp() on ppp0:

  • Are transmitted via AF_PACKET
  • End up with skb->protocol = 0
  • Are dropped in kernel:
ppp_start_xmit()
  -> ethertype_to_npindex(ntohs(skb->protocol)) -> -1
  -> drop

No packets are visible on the wire.

Expected result

The ping request should at lease appear on tcpdump output, and the ifconfig TX drop counter should not change when I send()/sendp().

Expected something like this (with my simple AF_PACKET test program):

# tcpdump -n -i ppp0 'icmp6'
tcpdump: verbose output suppressed, use -v[v]... for full protocol decode
listening on ppp0, link-type LINUX_SLL (Linux cooked v1), snapshot length 262144 bytes
13:14:02.186611 IP6 fe80::be24:11c6:ebd1:9865 > ff02::1: ICMP6, echo request, id 16494, seq 1, length 16
13:14:02.188890 IP6 fe80::[REDACTED] > fe80::be24:11c6:ebd1:9865: ICMP6, echo reply, id 16494, seq 1, length 16
^C
2 packets captured
2 packets received by filter
0 packets dropped by kernel

Related resources

packet_snd: https://elixir.bootlin.com/linux/v6.19.11/source/net/packet/af_packet.c#L2939
packet_snd is where skb->protocol assigned.

ppp_start_xmit: https://elixir.bootlin.com/linux/v6.19.11/source/drivers/net/ppp/ppp_generic.c#L1451
ppp_start_xmit is where packets are droped.

My test AF_PACKET program (written by GPT for test purpose only, compiles on debian 13 x86_64 GCC):

// ppp_ping_demo.c
#define _GNU_SOURCE
#include <arpa/inet.h>
#include <errno.h>
#include <linux/if_packet.h>
#include <net/if.h>
#include <netinet/icmp6.h>
#include <netinet/ip6.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <time.h>
#include <unistd.h>
#include <linux/if_ether.h>

static uint16_t csum16(const void *data, size_t len) {
    const uint8_t *p = (const uint8_t *)data;
    uint32_t sum = 0;
    while (len > 1) {
        sum += (p[0] << 8) | p[1];
        p += 2;
        len -= 2;
    }
    if (len) sum += p[0] << 8;
    while (sum >> 16) sum = (sum & 0xFFFF) + (sum >> 16);
    return (uint16_t)(~sum);
}

static uint16_t icmp6_checksum(const struct in6_addr *src,
                               const struct in6_addr *dst,
                               const void *icmp, size_t icmp_len) {
    // Pseudo-header
    struct {
        struct in6_addr src;
        struct in6_addr dst;
        uint32_t len;
        uint8_t zero[3];
        uint8_t next;
    } ph;

    memset(&ph, 0, sizeof(ph));
    ph.src = *src;
    ph.dst = *dst;
    ph.len = htonl((uint32_t)icmp_len);
    ph.next = IPPROTO_ICMPV6;

    // sum pseudo + icmp
    uint32_t sum = 0;
    sum += (~csum16(&ph, sizeof(ph)) & 0xFFFF);
    sum += (~csum16(icmp, icmp_len) & 0xFFFF);

    while (sum >> 16) sum = (sum & 0xFFFF) + (sum >> 16);
    return (uint16_t)(~sum);
}

static void usage(const char *p) {
    fprintf(stderr, "Usage: %s <ifname> <src-ll> <dst-addr>\n", p);
    fprintf(stderr, "Example: %s ppp0 fe80::1 ff02::1\n", p);
}

int main(int argc, char **argv) {
    if (argc != 4) {
        usage(argv[0]);
        return 1;
    }

    const char *ifname = argv[1];
    const char *src_str = argv[2];
    const char *dst_str = argv[3];

    int ifindex = if_nametoindex(ifname);
    if (!ifindex) {
        perror("if_nametoindex");
        return 1;
    }

    // fill in address (supports link local)
    struct in6_addr src, dst;
    if (inet_pton(AF_INET6, src_str, &src) != 1) {
        fprintf(stderr, "invalid src\n");
        return 1;
    }

    char dst_buf[256];
    memset(dst_buf, 0, sizeof(dst_buf));
    // for link-local, add %iface
    if (strchr(dst_str, '%') == NULL &&
        strncmp(dst_str, "fe80:", 5) == 0) {
        snprintf(dst_buf, sizeof(dst_buf), "%s%%%s", dst_str, ifname);
    } else {
        snprintf(dst_buf, sizeof(dst_buf), "%s", dst_str);
    }

    struct sockaddr_in6 dst_in6;
    memset(&dst_in6, 0, sizeof(dst_in6));
    dst_in6.sin6_family = AF_INET6;
    if (inet_pton(AF_INET6, dst_buf, &dst) != 1) {
        // inet_pton not supports %scope
        char tmp[256];
        strncpy(tmp, dst_buf, sizeof(tmp)-1);
        char *pct = strchr(tmp, '%');
        if (pct) *pct = '\0';
        if (inet_pton(AF_INET6, tmp, &dst) != 1) {
            fprintf(stderr, "invalid dst\n");
            return 1;
        }
    }

    // PF_PACKET + SOCK_DGRAM (set skb->protocol here)
    int fd = socket(AF_PACKET, SOCK_DGRAM, htons(ETH_P_IPV6));
    if (fd < 0) {
        perror("socket(AF_PACKET)");
        return 1;
    }

    // set dest ll address
    struct sockaddr_ll sll;
    memset(&sll, 0, sizeof(sll));
    sll.sll_family   = AF_PACKET;
    sll.sll_ifindex  = ifindex;
    sll.sll_protocol = htons(ETH_P_IPV6);
    // PPP has no L2 addr, so no sll_addr

    // construct IPv6 + ICMPv6 Echo Request
    uint8_t buf[1500];
    memset(buf, 0, sizeof(buf));

    struct ip6_hdr *ip6 = (struct ip6_hdr *)buf;
    struct icmp6_hdr *icmp = (struct icmp6_hdr *)(buf + sizeof(struct ip6_hdr));

    const char payload[] = "ppp-demo";
    size_t payload_len = sizeof(payload) - 1;

    // IPv6 header
    ip6->ip6_flow = htonl((6 << 28) | 0); // version + tc/flow
    ip6->ip6_plen = htons(sizeof(struct icmp6_hdr) + payload_len);
    ip6->ip6_nxt  = IPPROTO_ICMPV6;
    ip6->ip6_hlim = 255; // for ND/LL. hlim should be 255
    ip6->ip6_src  = src;
    ip6->ip6_dst  = dst;

    // ICMPv6 Echo
    icmp->icmp6_type = ICMP6_ECHO_REQUEST;
    icmp->icmp6_code = 0;
    icmp->icmp6_id   = htons((uint16_t)getpid());
    icmp->icmp6_seq  = htons(1);

    uint8_t *data = (uint8_t *)(icmp + 1);
    memcpy(data, payload, payload_len);

    // checksum
    icmp->icmp6_cksum = 0;
    icmp->icmp6_cksum = htons(icmp6_checksum(&src, &dst, icmp,
                                  sizeof(struct icmp6_hdr) + payload_len));

    size_t pkt_len = sizeof(struct ip6_hdr) + sizeof(struct icmp6_hdr) + payload_len;

    // sendp
    ssize_t n = sendto(fd, buf, pkt_len, 0,
                       (struct sockaddr *)&sll, sizeof(sll));
    if (n < 0) {
        perror("sendto");
        close(fd);
        return 1;
    }

    printf("sent %zd bytes on %s (proto=0x86dd)\n", n, ifname);
    close(fd);
    return 0;
}

Activity

  1. cinit commented on Apr 11, 2026

    @cinit
    Author

    Here's a workaround. socket.socket(socket.AF_PACKET, socket.SOCK_DGRAM) + s.bind((iface, socket.ETHERTYPE_IPV6)) + s.send(raw(pkt[IPv6])) can be used to send IPv6 packets on PPP link.

        def _native_send_ipv6(self, pkt: Packet, iface: str, is_multicast: bool) -> None:
            # make sure it's IPv6 packet
            if not pkt.haslayer(IPv6):
                LOG.warning("attempted to send non-IPv6 packet: %s", pkt.summary())
                return
            # 1. chek interface link type, raw IP or Ethernet
            ifarphdr = self._arphdr_type(iface)
            is_ethernet = ifarphdr == ArphrdType.ARPHRD_ETHER
            # 2. if raw IP, sendp as is; if Ethernet, add an Ethernet header with proper src and dst MAC addresses
            ret = None
            if is_ethernet:
                # ... skipped here
                ret = sendp(ether, iface=iface, verbose=False)
            elif ifarphdr == ArphrdType.ARPHRD_PPP:
                # PPP workaround, scapy has bug transmitting AF_PACKET on PPP link
                # We need to use scapy to build the L3 IPv4 packet
                # and manually send with AF_PACKET sll_proto=ETH_P_IPV6(0x86DD)
                l3_payload = raw(pkt[IPv6])
                # The addresses are represented by the tuple (ifname, proto[, pkttype[, hatype[, addr]]])
                sll = (iface, socket.ETHERTYPE_IPV6)
                with socket.socket(socket.AF_PACKET, socket.SOCK_DGRAM) as s:
                    s.bind(sll)
                    s.send(l3_payload)
            # elif other cases ...
    
        def _arphdr_type(self, ifname: str) -> Optional[ArphrdType]:
            try:
                with open(f"/sys/class/net/{ifname}/type", "r", encoding="utf-8") as f:
                    t = int(f.read().strip())
                    if t == ArphrdType.ARPHRD_ETHER.value:
                        return ArphrdType.ARPHRD_ETHER
                    elif t == ArphrdType.ARPHRD_PPP.value:
                        return ArphrdType.ARPHRD_PPP
                    elif t == ArphrdType.ARPHRD_VOID.value:
                        return ArphrdType.ARPHRD_VOID
                    else:
                        raise ValueError(f"unsupported ARPHRD type {t} for interface {ifname}")
            except OSError as e:
                # unexpected
                LOG.error("failed to read ARPHRD type for interface %s: %s", ifname, e)
                raise e
  2. added theissue type on Apr 11, 2026
  3. mohamedsolaiman commented on May 1, 2026

    @mohamedsolaiman
    Contributor

    I ran into a similar issue with PPPoE interfaces. The problem is that when Scapy sends via AF_PACKET on PPPoE, it does not set sll_protocol correctly for the PPP driver. A workaround that worked for me was to use conf.L2socket = L2ListenTcpdump and set the protocol field manually. Not a perfect fix but it gets packets through. Would love to see proper PPPoE support in Scapy!

  4. added 2 commits that reference this issue on Jun 15, 2026
    b09dfbf
    f02b611
  5. added this to the next milestone on Jul 22, 2026
  6. DresslerFrank commented on Oct 11, 2026

    @DresslerFrank
    Contributor

    I have done some research on this issue, looking at the kernel source and debugging the kernel. See the description below. My private notes are more comprehensive. I am not describing everything below but I hope it is sufficient to help you fix the problem. If you want, I can actually work more on it and fix it. At least I want to share my findings here because they might actually help others fix the issue properly.

    Quick workaround

    Explicitly pass the protocol to send(). This allows you to send packets via PPPoE and similar devices:

    • send(IP()/..., type=ETH_P_IP)
    • send(IPv6()/..., type=ETH_P_IPV6)
    • ...

    Explicitly specifying the protocol helps because it makes Scapy create the socket with that type/protocol instead of ETH_P_ALL. The ETH_P_ALL only works with some netdev types, e.g. Ethernet ones, but not others, e.g. PPP ones. See below for details.

    Issue is more general

    The issue is not PPPoE specific. It applies to all kinds of PPP netdevs and also to other kinds of netdevs such as GRE ones. In general, it applies to all netdevs that require you to pass the protocol to the kernel via e.g. socket(), bind(), or sendto(). Details below.

    Importance of the Protocol in AF_PACKET, SOCK_RAW sockets

    When you have a (AF_PACKET, SOCK_RAW) socket, what you need to pass to the kernel depends on the kind of netdev. Independent of the netdev the kernel needs to know the protocol (same numbers as ethertype) of the packet you are sending in. The kernel takes that information from either socket(), bind(), or sendto(). If that protocol is ETH_P_ALL or 0, the kernel tries to help you out by taking a look at the packet but this depends on the specific netdev type.

    • For Ethernet devices, the kernel expects you to pass Ether()/... to the kernel. And because the Ether() contains the ether type, the kernel will just extract the protocol from the packet, if none was passed explicitly via socket()/bind()/sendto().

    • For PPP devices, the kernel expects you to pass IP()/..., IPv6()/... to it, i.e. no header from which it could extract the protocol. Because of that, the kernel cannot help you out. So, if your socket was created with ETH_P_ALL or bind() to ETH_P_ALL, and you do not specify a protocol explicitly in sendto(), the kernel has no way to know the protocol. It'll rewrite the protocol from ETH_P_ALL (3) to 0. The PPP code, however, needs to know the protocol because the protocol ID must be translated to another kind of protocol ID and placed in the PPP header the kernel adds to the packet before sending it. Since 0 is not a valid protocol, the kernel rejects the packet. The reason for why the kenrel cannot help you detect the protocol automatically is that PPP can carry not just IP and IPv6, but also IPX and other protocols.

    • For GRE devices, it is similar to PPP devices but a bit less restrictive. GRE devices also expect you to pass IP()/..., IPv6() and because of that cannot extract the protocol ID from the packet. But for GRE, the protocol ID is simply copied 1:1 to a 16 bit field in the GRE header with no translation. This means, if you do not pass a protocol to the kernel, the kernel will turn the ETH_P_ALL to 0 and, while that 0 is wrong, the kernel does not care and just puts this 0 into the GRE header. It should be ETH_P_IP or ETH_P_IPV6 instead but the kernel does not care. 0 is a valid value for GRE. The kernel would not drop the packet, it'd be sent.

    • For SIT devices, we have a case similar to PPP and GRE, where again you pass IP()/..., IPV6()/... or whatever to the kernel, i.e. no protocol as part of what you pass to it. However, SIT is limited to carry IPv4 or IPv6. Because of that, the kernel can actually help you autoamtically detect the protocol. Both, IPv4 and IPv6, have the protocol version in the top nibble of their first byte. So what the kernel does is look at that nibble and if it is 4, it has detected an IPv4 packet, and if it is 6, it has detected an IPv6 packet.

    Device Type Property Overview

    netdev type expected input auto detect protocol permitted protocols restricted works properly with scapy
    Ethernet, IPVLAN, MACVLAN Ether()/... yes, from ether type no yes
    PPP IP()/..., IPV6()/..., ... no yes: ETH_P_IP, ETH_P_IPV6, ETH_P_IPX, ETH_P_PPPTALK, ETH_P_ATALK, ETH_P_MPLS_UC, ETH_P_MPLS_MC no
    GRE IP()/..., IPV6()/..., ... no no no
    SIT, Wireguard, TUN IP()/..., IPV6()/... yes for IPv4/IPv6 I don't know yes

    The ones not working properly with Scapy are the ones that do not have automatic protocol detection. GRE seems to work because packets are sent and received, but they will be sent with the wrong protocol (0).

    Scapy

    Currently, usually, Scapy does not pass the protocol to the kernel explicitly but instead uses ETH_P_ALL and send(). Because of that, only netdevs for which the kernel supports automatic protocol detections work, i.e. regular Ethernet devices, but also SIT or Wireguard would work, for example. See table above.

    However, when you specify the protocol explicitly, i.e.send(..., type=PROTOCOL), Scapy does bind(..., PROTOCOL) which is why it then works: The kernel does not need to automatically detect the protocol and so it works for netdevs that do not support automatic protocol detection.

    Fixing the Issue

    bind()ing the socket to the desired protocol would be one way of solving the issue. However, that'd restrict the socket a bit too much. It'd then be restricted to just that protocol, e.g. IPv4 or IPv6. You could not use one socket for multiple protocols anymore. Limiting a socket to a specific protocol would be unnecessarily restrictive and it is unnecessary as the following paragraph shows.

    What you can do instead is to use sendto() instead of send(). sendto() allows you to pass the struct sockaddr_ll to the kernel with the packet data. This struct contains the desitnation address, the device to use, but also the protocol and some other fields. For the (AF_PACKET, SOCK_RAW) sockets Scapy uses, the kernel actually ignores all fields except for the interface and protocol. And Python already supports this by allowing you to pass just these two instead of forcing you to make up values for all the other fields. You can just do a s.sendto(bytes(pkt), ("ppp0", ETH_P_IP)) or s.sendto(bytes(pkt), ("ppp0", ETH_P_IPV6)) instead of a s.send() that Scapy currently does. To help the user, we can extract the protocol ID from the pkt.

    So you'd get the following change in code.

    Before:

    sock.send(bytes(pkt))

    After:

    protocol = ethertype_from_pkt(pkt)
    interface = sock.getsockname()[0]
    sock.sendto(bytes(pkt), (interface, protocol))

    This would basically move the protocol detection to Scapy. The kernel would not need to do it anymore. But, I wonder, if that might be slow and sometimes undesired. So maybe it should be restricted to when the socket sock is bound to ETH_P_ALL or 0 which is when the kernel would do the auto-detection and potentially further restricted to only those kinds of sockets which are bound to device types that do not support automatic protocol detection. The solution above is just one that probably always works for as long as ethertype_from_pkt(pkt) works which requires pkt to be a Packet, e.g. IP()/... or similar, i.e.it could not be a bytes() thing already. In that situation, you could have ethertype_from_pkt implemented as follows:

    def ethertype_from_pkt(pkt):
        import scapy.layers.l2
        try: return pkt._overload_fields[scapy.layers.l2.Ether]["type"
        except: pass

    Since that function could fail and restricts the pkt to actual Packets, excluding bytes(), it might make sense to really restrict it to netdevs for which the kernel has no protocol auto detection. That'd then allow users to still pass bytes(). For send() specifically, the user currently cannot pass bytes(), so the given code would not be restrictive.

    Sample Patch

    The following diff/patch shows a potential change that makes send() work for PPP and similar devices that require you to pass down the protocol explicitly. It is still flawed because it would not work with the auto-fragmentation code just below. Use it only as an example to prove the concept. Bascially, all sock.send()s need to be replaced by a conditional sock.sendto() where necessary (netdev does not support protocol auto detection and user did not explicitly specify a protocol) with a fallback to sock.send() where the kernel can detect the protocol explicitly.

    diff --git a/scapy/arch/linux/__init__.py b/scapy/arch/linux/__init__.py
    index 29ad12cd..3ad6f1f2 100644
    --- a/scapy/arch/linux/__init__.py
    +++ b/scapy/arch/linux/__init__.py
    @@ -51,6 +51,11 @@ from scapy.arch.linux.rtnetlink import (  # noqa: F401
         _get_if_list,
     )
     
    +def getethertype(pkt):
    +    import scapy.layers.l2
    +    try: return pkt._overload_fields[scapy.layers.l2.Ether]["type"]
    +    except: pass
    +
     # Typing imports
     from typing import (
         Any,
    @@ -374,8 +379,18 @@ class L3PacketSocket(L2Socket):
                 x.sent_time = time.time()
             except AttributeError:
                 pass
    +        socknam = fd.getsockname()
    +        if socknam[1] == ETH_P_ALL:
    +            protocol = getethertype(x)
    +            if protocol is not None:
    +                socknam = (socknam[0], protocol)
    +            else:
    +                socknam = None
             try:
    -            return fd.send(sx)
    +            if socknam is not None:
    +                return fd.sendto(sx, socknam)
    +            else:
    +                return fd.send(sx)
             except socket.error as msg:
                 if msg.errno == 22 and len(sx) < conf.min_pkt_size:
                     return fd.send(
  7. DresslerFrank commented on Oct 11, 2026

    @DresslerFrank
    Contributor

    Here are two helper scripts that allow you to create a test environment for the issue.

    The first script creates two network name spaces, scapy_L (L for left) and scapy_R (R for right) with different kinds of netdev types of the ones described above between them. Enter the network name spaces via ip netns exec scapy_L bash and ip netns exec scapy_R bash, then use the devices, e.g. via send(IP(dst="10.102.0.2")/...) from scapy_L with tcpdump running in scapy_R.

    The second script injects packets in various ways to demonstrate the effects of bind() vs sendto() and the different netdev types (eth, ppp, gre, ...). Run it from spectral_L like ./inject.py veth1, ./inject.py gre1, ... and have tcpdump -lXnni gre1 udp, tcpdump -lXnni veth1 udp, ... running in spectral_R. The tcpdump output will prove which ones work.

    Of course, with that test env, you can test whether the approach in my shared patch above works. Do a send(IP(src="10.103.0.1", dst="10.103.0.2")/UDP()/(128*b"x")) from the scapy CLI running in spectral_L with and without the patch. With the patch it'll work. Without it it wont. But if you add type=ETH_P_IP to the send(...) it'll work even without the patch.

    SetupTest Env

    Run as root, then enter the name spaces via ip netns exec scapy_L bash and ip netns exec scapy_R bash

    #!/bin/bash                                                                                                                                                                                                                                                                                
                                                                                                                                                                                                                                                                                               
    NETNS_PREFIX=${NETNS_PREFIX:-scapy}                                                                                                                                                                                                                                                        
    NETNS_L=${NETNS_PREFIX}_L                                                                                                                                                                                                                                                                  
    NETNS_R=${NETNS_PREFIX}_R                                                                                                                                                                                                                                                                  
                                                                                                                                                                                                                                                                                               
    check_command() {                                                                                                                                                                                                                                                                          
      local CMD=$1 STR=$2                                                                                                                                                                                                                                                                      
      command -v "$CMD" >/dev/null 2>&1                                                                                                                                                                                                                                                        
    }                                                                                                                                                                                                                                                                                          
                                                                                                                                                                                                                                                                                               
    exit_with() {                                                                                                                                                                                                                                                                              
      local RET=$1 MSG=$2                                                                                                                                                                                                                                                                      
      printf "ERROR: %s\n" "$MSG" >&2                                                                                                                                                                                                                                                          
      exit "$RET"                                                                                                                                                                                                                                                                              
    }                                                                                                                                                                                                                                                                                          
    
    check_command ip || exit_with 1 "ip missing. install iproute2"
    check_command socat || exit_with 1 "socat missing. install socat"
    check_command pppd || exit_with 1 "pppd missing. install ppp"
    
    # reset
    netns_reset() {
      ip netns del "$NETNS_L" 2>/dev/null || true
      ip netns del "$NETNS_R" 2>/dev/null || true
    }                                                                     
    
    # setup                                                               
    netns_setup() {                                                       
      ip netns add "$NETNS_L"                                             
      ip netns add "$NETNS_R"                                             
      ip -n "$NETNS_L" l s lo up                                          
      ip -n "$NETNS_R" l s lo up                                          
    }                                                                     
    
    # veth                                                                
    veth() {                                                              
      ip -n "$NETNS_L" l a veth1 type veth peer netns "$NETNS_R" name veth1
      ip -n "$NETNS_L" l s veth1 up                                       
      ip -n "$NETNS_R" l s veth1 up                                       
      ip -n "$NETNS_L" a a 10.100.0.1/24 dev veth1
      ip -n "$NETNS_R" a a 10.100.0.2/24 dev veth1
    }                                                                     
    
    # gre                                                                 
    gre() {                                                               
      ip -n "$NETNS_L" l a gre1 type gre local 10.100.0.1 remote 10.100.0.2
      ip -n "$NETNS_R" l a gre1 type gre local 10.100.0.2 remote 10.100.0.1
      ip -n "$NETNS_L" l s gre1 up                                        
      ip -n "$NETNS_R" l s gre1 up                                        
      ip -n "$NETNS_L" a a 10.101.0.1/24 dev gre1
      ip -n "$NETNS_R" a a 10.101.0.2/24 dev gre1
    }                                                                     
    
    # sit                                                                 
    sit() {                                                               
      ip -n "$NETNS_L" l a sit1 type sit local 10.100.0.1 remote 10.100.0.2
      ip -n "$NETNS_R" l a sit1 type sit local 10.100.0.2 remote 10.100.0.1
      ip -n "$NETNS_L" l s sit1 up                                        
      ip -n "$NETNS_R" l s sit1 up                                        
      ip -n "$NETNS_L" a a 10.102.0.1/24 dev sit1
      ip -n "$NETNS_R" a a 10.102.0.2/24 dev sit1
    }                                                                     
    
    # ppp                                                                 
    ppp() {                                                               
      trap 'jobs -pr | xargs kill 2>/dev/null' EXIT
      socat PTY,link=/run/ppp-"$NETNS_L",rawer,echo=0 PTY,link=/run/ppp-"$NETNS_R",rawer,echo=0 &
      ip netns exec "$NETNS_L" pppd /run/ppp-"$NETNS_L" 115200 noauth local nodetach 10.103.0.1:10.103.0.2 &
      ip netns exec "$NETNS_R" pppd /run/ppp-"$NETNS_R" 115200 noauth local nodetach &
      wait                                                                
    }                                                                     
    
    netns_reset                                                           
    netns_setup                                                           
    veth                                                                  
    gre                                                                   
    sit                                                                   
    ppp                                                                   

    Inject packets

    Run from scapy_L netns via ./inject.py veth1, ./inject.py gre1, ... and run tcpdump from scapy_R while doing so.

    #!/usr/bin/python3
    
    import socket
    from scapy.all import *
    
    def bind_all_send(iface, pkt):
        s = socket.socket(socket.AF_PACKET, socket.SOCK_RAW, socket.htons(socket.ETH_P_ALL))
        s.bind((iface, socket.ETH_P_ALL))
        s.send(bytes(pkt)) # <- ETH_P_ALL from the `bind` above
    
    def bind_ip_send(iface, pkt):
        s = socket.socket(socket.AF_PACKET, socket.SOCK_RAW, socket.htons(socket.ETH_P_ALL))
        s.bind((iface, ETH_P_IP))
        s.send(bytes(pkt)) # <- ETH_P_IP from `bind` above
    
    def bind_all_sendto(iface, pkt):
        s = socket.socket(socket.AF_PACKET, socket.SOCK_RAW, socket.htons(socket.ETH_P_ALL))
        s.bind((iface, socket.ETH_P_ALL))
        s.sendto(bytes(pkt), (iface, ETH_P_IP)) # <- ETH_P_IP because it is passed here
    
    def nobind_sendto(iface, pkt):
        s = socket.socket(socket.AF_PACKET, socket.SOCK_RAW, socket.htons(socket.ETH_P_ALL))
        s.sendto(bytes(pkt), (iface, ETH_P_IP)) # <- ETH_P_IP because it is passed here
    
    def build_l2_pkt(sip, dip, payload):
        return Ether()/IP(src=sip, dst=dip)/UDP()/payload
    
    def build_l3_pkt(sip, dip, payload):
        return IP(src=sip, dst=dip)/UDP()/payload
    
    def veth1():
        # veth devices are ethernet devices
        # ethernet (AF_PACKET, SOCK_RAW) sockets expect you to pass the ethernet header as in
        # which includes the ethertype field from which Linux extracts the protocol if it was
        # not already specified in socket()/bind()/sendto()
        # because of this the first case works. the kernel will just extract the protocol from
        # the ethertype field.
        bind_all_send("veth1", build_l2_pkt("10.100.0.1", "10.100.0.2", b"veth1_bind_all_send"))
        bind_all_sendto("veth1", build_l2_pkt("10.100.0.1", "10.100.0.2", b"veth1_bind_all_sendto"))
        bind_ip_send("veth1", build_l2_pkt("10.100.0.1", "10.100.0.2", b"veth1_bind_ip_send"))
        nobind_sendto("veth1", build_l2_pkt("10.100.0.1", "10.100.0.2", b"veth1_nobind_sendto"))
    
    def gre1():
        # GRE (AF_PACKET, SOCK_RAW) sockets just expect you to pass the IP/IPXv6/whatever packet
        # in without the GRE header or any other header that could indicate the protocol of
        # what you are passing in. because of that, the kernel cannot extract the protocol from
        # what you pass in. you need to expclitly pass that information to the kernel via
        # socket()/bind()/sendto(). That is why the `bind_all_send()` below does not work:
        #  The protocol is not specified (it is ETH_P_ALL) and it is not explicitly passed
        #  in the send() call.
        # All others work because they define the protocol in bind() or sendto()
        # The kernel constructs the GRE header itself and copies the protocol to the header
        # unchanged. That is why you can pass any protocol you like to it, nothing is rejected
        # or translated based on it.
        bind_all_send("gre1", build_l3_pkt("10.101.0.1", "10.101.0.2", b"gre1_bind_all_send"))
        bind_all_sendto("gre1", build_l3_pkt("10.101.0.1", "10.101.0.2", b"gre1_bind_all_sendto"))
        bind_ip_send("gre1", build_l3_pkt("10.101.0.1", "10.101.0.2", b"gre1_bind_ip_send"))
        nobind_sendto("gre1", build_l3_pkt("10.101.0.1", "10.101.0.2", b"gre1_nobind_sendto"))
    
    def sit1():
        # SIT (Af_PACKET, SOCK_RAW) sockets, like GRE, expect you to pass IP/IPv6/...
        # Since SIT can only carry IPv4 and IPv6, the kernel does not need you to pass
        # the protocol to it. Both IPv4 and IPv6 headers start with the version number in the top
        # nibble of the first byte. It is 4 for IPv4 and 6 for IPv6. That is what the kernel uses
        # to help you out. This is different from GRE where, because you transport anything over GRE,
        # the kernel has no way to reliably automatically detect the protocol ID of the packet you
        # send.
        bind_all_send("sit1", build_l3_pkt("10.102.0.1", "10.102.0.2", b"sit1_bind_all_send"))
        bind_all_sendto("sit1", build_l3_pkt("10.102.0.1", "10.102.0.2", b"sit1_bind_all_sendto"))
        bind_ip_send("sit1", build_l3_pkt("10.102.0.1", "10.102.0.2", b"sit1_bind_ip_send"))
        nobind_sendto("sit1", build_l3_pkt("10.102.0.1", "10.102.0.2", b"sit1_nobind_sendto"))
    
    def ppp0():
        # PPP (AF_PACKET, SOCK_RAW) sockets, like GRE and SIT, expect just the packet without the
        # PPP header to be passed to the kernel. Like with GRE and unlike SIT it cannot help you
        # detect the protocol, you need to pass it down. This is because PPP supports IPv4, IPv6,
        # but also IPX and other protocols to be carried over it and needs to translate the protocol
        # IDs. Because that translation table is limited, only a specific set of protocol numbers
        # are supported. You can issue a sendto with protocl ID 0x1234 but the PPP code will drop
        # that packet because it does not know how to translate 0x1234 to the PPP protocol ID.
        # In GRE, 0x1234 would work since it just copies the 0x1234 to the GRE header unchanged.
        # Because of that, the `bind_all_send` won't work, since the kernel has no automatic
        # detection of the protocol, just like with GRE.
        bind_all_send("ppp0", build_l3_pkt("10.103.0.1", "10.103.0.2", b"ppp0_bind_all_send"))
        bind_all_sendto("ppp0", build_l3_pkt("10.103.0.1", "10.103.0.2", b"ppp0_bind_all_sendto"))
        bind_ip_send("ppp0", build_l3_pkt("10.103.0.1", "10.103.0.2", b"ppp0_bind_ip_send"))
        nobind_sendto("ppp0", build_l3_pkt("10.103.0.1", "10.103.0.2", b"ppp0_nobind_sendto"))
    
    import sys
    funcs = {
        "veth1": veth1,
        "gre1": gre1,
        "sit1": sit1,
        "ppp0": ppp0
    }
    name = sys.argv[1]
    if name in funcs:
        funcs[name]()
  8. gpotter2 commented on Oct 12, 2026

    @gpotter2
    Member

    @DresslerFrank I encourage you to read the CONTRIBUTING.md guidelines. You may NOT comment using AI on the Scapy repo. Comments are for talking to humans, please don't be rude.

  9. DresslerFrank commented on Oct 12, 2026

    @DresslerFrank
    Contributor

    [Conslidated my previous comments into one.]

    @gpotter2 I did not use AI. I have done that research myself. Let me share my notes (research_notes.md). It is really not nice of you to just accuse me of it. I have spent many hours, over 2 days doing the research.

    I also started working on solving the problem. But with your accusation I am now really demotivated. Here is the test I have written so far (scapy_gre_test.py) that I was still working on, to test that GRE works. Because if that works, PPP also works.

    What makes you believe that I created my comments using AI? I can share my screen and show you how I kernel debugged it. AI won't be able to do that. Let me know where you want me to share my screen. Would you take back the accusation then?

    You can contact me on LinkedIn (https://www.linkedin.com/in/frank-dressler-5a0b391a2) or via email frank@dressler.pro I'll find a way to share my screen then and prove to you I actually did the research.

    I have sent you an email using the email address I found in the source code of Scapy. Jitsi seems to be a working way I can use to share my screen.

  10. gpotter2 commented on Oct 12, 2026

    @gpotter2
    Member

    Hi @DresslerFrank, apologies if I misattributed your comments to AI. We almost never get answers this long and with heavy formatting that aren't AI-slop. (and trust me right now we're flooded with it..)
    Thank you, of course, for contributing to the understanding of this issue and sorry for the confusion

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    Projects

    No projects

      Milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions